diff --git a/.cspell.yaml b/.cspell.yaml
index d0bb04e..147a63b 100644
--- a/.cspell.yaml
+++ b/.cspell.yaml
@@ -72,6 +72,7 @@ words:
- collinear
- collinearity
- collinearly
+ - pathfinding
# Non-ASCII terms retained verbatim in extracted source comments
- façade
- Köpf
diff --git a/docs/design/rendering-layout/connector-router.md b/docs/design/rendering-layout/connector-router.md
index 3e9f50c..0d538d5 100644
--- a/docs/design/rendering-layout/connector-router.md
+++ b/docs/design/rendering-layout/connector-router.md
@@ -62,8 +62,13 @@ implementations are:
the margin. The result is then clamped into the chosen face's usable connectable extents (again
applying the same clearance inset when an extent is long enough) and projected inward to the real
outline. The chosen `PortSide` is retained so the route exits and enters perpendicular to the face.
-2. **Obstacle set.** Builds a `Rect` per box, excluding the connection's two endpoint boxes matched
- by reference identity, so the connector is free to leave and enter the boxes it joins.
+2. **Obstacle set.** Builds a `Rect` per box, including the connection's own two endpoint boxes. The
+ connector remains free to leave and enter the boxes it joins because the underlying
+ `OrthogonalEdgeRouter` steps each anchor off its face by a perpendicular stub longer than any
+ clearance level it tries before pathfinding, then reattaches the true anchor when assembling the
+ final path; treating endpoints as ordinary obstacles closes the gap through which an unrelated,
+ already-routed connector's soft obstacles could otherwise squeeze a later connector into its own
+ target box's interior.
3. **Dispatch.** Routes through the router realizing `options.EdgeRouting`. Today `Orthogonal` maps to
the internal `OrthogonalEdgeRouter.RouteWithStatus`, which is the implementation behind the enum
value and remains internal to the Layout system. The dispatch is a single-arm switch structured so
diff --git a/docs/gallery/README.md b/docs/gallery/README.md
index c5d258e..f43a081 100644
--- a/docs/gallery/README.md
+++ b/docs/gallery/README.md
@@ -34,6 +34,13 @@ layer with a dummy bend point created by an unrelated long edge (LongSource to L
clusters isolated nodes at the end of the layer's order, and coordinate assignment squeezes any resulting gap down to
the standard node spacing, instead of inheriting the dummy's unrelated port-alignment floor as an inflated gap.
+
+
+Regression coverage for the parallel-edges-into-compartment-box fix: nine unmerged edges from a small Source box
+converge on a taller Target box's nine-row compartment. ConnectorRouter now treats every box, including a connection's
+own endpoints, as a hard obstacle for the whole route (not just the final docking stub), so a connector squeezed by
+other already-routed connectors can no longer detour straight through its own target box's interior.
+
## Flow direction
The same directed graph laid out in two flow directions, selected with the direction option, plus a nested container
diff --git a/docs/gallery/parallel-edges-into-compartment-box.svg b/docs/gallery/parallel-edges-into-compartment-box.svg
new file mode 100644
index 0000000..8bb103a
--- /dev/null
+++ b/docs/gallery/parallel-edges-into-compartment-box.svg
@@ -0,0 +1,57 @@
+
diff --git a/docs/reqstream/rendering-layout/connector-router.yaml b/docs/reqstream/rendering-layout/connector-router.yaml
index 39bf8db..a6ba294 100644
--- a/docs/reqstream/rendering-layout/connector-router.yaml
+++ b/docs/reqstream/rendering-layout/connector-router.yaml
@@ -63,14 +63,17 @@ sections:
- id: Rendering-Layout-ConnectorRouter-ExcludesEndpoints
title: >-
- ConnectorRouter shall exclude a connection's two endpoint boxes, matched by instance
- identity, from the obstacle set used to route that connection.
+ ConnectorRouter shall treat a connection's own endpoint boxes as ordinary obstacles while
+ still letting the connector leave and enter their faces, by routing through a perpendicular
+ approach stub that clears each endpoint before the true anchor is reattached.
justification: |
- A connector must be free to leave and enter the boxes it joins; treating the endpoint boxes
- as obstacles would block the connector's own anchors. Endpoints are matched by reference so
- no domain concept enters the routing.
+ Excluding endpoint boxes entirely left a gap through which an unrelated, already-routed
+ connector's soft obstacles could squeeze a later connector into its own target box's
+ interior. Treating endpoints as solid obstacles and relying on the router's existing
+ stub-then-dock approach keeps the connector's own docking free while closing that gap.
tests:
- - Route_EndpointBoxes_AreExcludedFromObstacles
+ - Route_EndpointBoxes_ReachBoundaryAnchorsViaApproachStub
+ - Route_NineParallelEdgesIntoCompartmentBox_DoNotCrossTargetInterior
- id: Rendering-Layout-ConnectorRouter-CarriesStyling
title: >-
diff --git a/docs/verification/rendering-layout/connector-router.md b/docs/verification/rendering-layout/connector-router.md
index de71cd7..178e844 100644
--- a/docs/verification/rendering-layout/connector-router.md
+++ b/docs/verification/rendering-layout/connector-router.md
@@ -26,7 +26,7 @@ obstacle avoidance, and produced `LayoutLine` styling are all observed on real o
A verification run passes when every named scenario below asserts without unexpected exception, and
the referenced tests cover each `Rendering-Layout-ConnectorRouter-*` requirement. Any wrong anchor
-face, waypoint that enters a non-endpoint obstacle interior, incorrect line styling passthrough,
+face, waypoint that enters an obstacle's strict interior, incorrect line styling passthrough,
out-of-order batch result, or non-argument-null exception for invalid input constitutes a failure.
### Test Scenarios
@@ -51,9 +51,12 @@ out-of-order batch result, or non-argument-null exception for invalid input cons
- **Obstacle avoidance** (`Rendering-Layout-ConnectorRouter-AvoidsObstacles`):
`Route_ObstacleBetweenEndpoints_RoutesAroundInterior` confirms the route is orthogonal and never
enters an intervening box's interior.
-- **Endpoint exclusion** (`Rendering-Layout-ConnectorRouter-ExcludesEndpoints`):
- `Route_EndpointBoxes_AreExcludedFromObstacles` confirms the connector reaches the endpoints' boundary
- anchors even though both boxes appear in the box list.
+- **Endpoint boxes as ordinary obstacles** (`Rendering-Layout-ConnectorRouter-ExcludesEndpoints`):
+ `Route_EndpointBoxes_ReachBoundaryAnchorsViaApproachStub` confirms the connector still reaches the
+ endpoints' boundary anchors even though both boxes are treated as obstacles, and
+ `Route_NineParallelEdgesIntoCompartmentBox_DoNotCrossTargetInterior` confirms a batch of parallel
+ connectors converging on one box face never cuts through that box's (or the source box's) interior,
+ reproducing a previously reported defect.
- **Styling carried** (`Rendering-Layout-ConnectorRouter-CarriesStyling`):
`Route_Connection_CarriesRequestedStyling` confirms the requested target marker, line style, and
label flow onto the produced line while the source end stays unmarked.
@@ -94,7 +97,8 @@ level rather than introducing a new `ConnectorRouter`-level responsibility:
- **`Rendering-Layout-ConnectorRouter-AvoidsObstacles`**:
Route_ObstacleBetweenEndpoints_RoutesAroundInterior
- **`Rendering-Layout-ConnectorRouter-ExcludesEndpoints`**:
- Route_EndpointBoxes_AreExcludedFromObstacles
+ Route_EndpointBoxes_ReachBoundaryAnchorsViaApproachStub,
+ Route_NineParallelEdgesIntoCompartmentBox_DoNotCrossTargetInterior
- **`Rendering-Layout-ConnectorRouter-CarriesStyling`**:
Route_Connection_CarriesRequestedStyling
- **`Rendering-Layout-ConnectorRouter-BatchOrder`**:
diff --git a/src/DemaConsulting.Rendering.Abstractions/BoxMetrics.cs b/src/DemaConsulting.Rendering.Abstractions/BoxMetrics.cs
index 85d5c67..976676e 100644
--- a/src/DemaConsulting.Rendering.Abstractions/BoxMetrics.cs
+++ b/src/DemaConsulting.Rendering.Abstractions/BoxMetrics.cs
@@ -26,6 +26,25 @@ public static double FolderTabHeight(Theme theme)
return theme.FontSizeBody + 2.0 * theme.LabelPadding;
}
+ ///
+ /// Computes the size of the folded-corner cutout drawn at the top-right of a
+ /// box: a fraction of the box's shorter side
+ /// (), capped at
+ /// . The same value is the horizontal inset from
+ /// the right edge and the vertical inset from the top edge, since the fold cutout is a
+ /// right-angle triangle. Both renderers, and anything reasoning about how close a horizontal
+ /// divider may safely approach a Note box's top edge, must call this so the fold's drawn
+ /// geometry and any content clearance never disagree.
+ ///
+ /// The Note-shaped box whose fold size is resolved.
+ /// The fold size in logical pixels.
+ /// is .
+ public static double NoteFoldSize(LayoutBox box)
+ {
+ ArgumentNullException.ThrowIfNull(box);
+ return Math.Min(Math.Min(box.Width, box.Height) * NotationMetrics.NoteFoldFraction, NotationMetrics.NoteFoldMaxSize);
+ }
+
///
/// Computes the height of the title area of a box: the vertical space reserved at the top
/// for the optional keyword line and the bold name line.
diff --git a/src/DemaConsulting.Rendering.Abstractions/DemaConsulting.Rendering.Abstractions.csproj b/src/DemaConsulting.Rendering.Abstractions/DemaConsulting.Rendering.Abstractions.csproj
index 6c9dc4f..80e0758 100644
--- a/src/DemaConsulting.Rendering.Abstractions/DemaConsulting.Rendering.Abstractions.csproj
+++ b/src/DemaConsulting.Rendering.Abstractions/DemaConsulting.Rendering.Abstractions.csproj
@@ -39,6 +39,7 @@
true
+ truetrue
@@ -74,6 +75,8 @@
+
+
diff --git a/src/DemaConsulting.Rendering.Abstractions/ILayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Abstractions/ILayoutAlgorithm.cs
deleted file mode 100644
index 3f6e022..0000000
--- a/src/DemaConsulting.Rendering.Abstractions/ILayoutAlgorithm.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-//
-// Copyright (c) DemaConsulting. All rights reserved.
-//
-
-using DemaConsulting.Rendering;
-
-namespace DemaConsulting.Rendering.Abstractions;
-
-///
-/// A pluggable layout algorithm: the high-level, ELK-inspired extension point. An algorithm consumes
-/// an unplaced plus and produces a placed
-/// ready for an . Additional diagram families (tree,
-/// force, packing, and so on) are introduced purely additively by implementing this interface and
-/// registering the implementation; no existing contract changes.
-///
-public interface ILayoutAlgorithm
-{
- ///
- /// Gets the stable identifier used to select this algorithm (for example layered). Matches
- /// the value read from .
- ///
- string Id { get; }
-
- ///
- /// Computes a placed layout for using the supplied options.
- ///
- /// The unplaced input graph.
- ///
- /// Configuration for this run. Properties the algorithm does not understand are ignored, so
- /// callers may pass options intended for other algorithms without error.
- ///
- /// A fully-placed with absolute coordinates and routed edges.
- ///
- /// Thrown when or is .
- ///
- LayoutTree Apply(LayoutGraph graph, LayoutOptions options);
-}
diff --git a/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmBase.cs b/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmBase.cs
new file mode 100644
index 0000000..c678f43
--- /dev/null
+++ b/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmBase.cs
@@ -0,0 +1,79 @@
+//
+// Copyright (c) DemaConsulting. All rights reserved.
+//
+
+using DemaConsulting.Rendering;
+
+namespace DemaConsulting.Rendering.Abstractions;
+
+///
+/// A pluggable layout algorithm: the high-level, ELK-inspired extension point. An algorithm consumes
+/// an unplaced and produces a placed ready for an
+/// . Additional diagram families (tree, force, packing, and so on) are
+/// introduced purely additively by deriving from this class and registering the implementation; no
+/// existing contract changes.
+///
+///
+///
+/// Single configuration surface. A graph is an , so
+/// every a layout should honor — including which algorithm applies — is
+/// configured directly on the graph (or, for a nested container's own scope, on its
+/// graph). is therefore the
+/// only public entry point and takes no separate options argument: there is exactly one way to
+/// configure a run, so a caller can never supply settings that contradict the graph's own.
+///
+///
+/// Extensibility without a second public surface. A hierarchical or composite
+/// algorithm still needs to recurse into a nested scope with that scope's own cascaded
+/// (parent-resolved) options — for example a hierarchical algorithm resolving a container's
+/// sub-layout. That recursion is exposed only to other algorithm implementations, via
+/// the protected internal extension
+/// point: accessible to a derived class (the protected half) and, through
+/// InternalsVisibleTo, to the bundled algorithms in
+/// DemaConsulting.Rendering.Layout (the internal half), but never to an external
+/// caller, who can only reach the sealed, single-argument .
+///
+///
+public abstract class LayoutAlgorithmBase
+{
+ ///
+ /// Gets the stable identifier used to select this algorithm (for example layered). Matches
+ /// the value read from .
+ ///
+ public abstract string Id { get; }
+
+ ///
+ /// Computes a placed layout for . Every setting the layout should honor
+ /// (algorithm, direction, edge routing, spacing, and so on) is read directly from
+ /// itself, since it is an ; this is the only
+ /// way to configure a run, so there is no second options argument to disagree with the graph.
+ ///
+ /// The unplaced input graph.
+ /// A fully-placed with absolute coordinates and routed edges.
+ /// Thrown when is .
+ public LayoutTree Apply(LayoutGraph graph)
+ {
+ ArgumentNullException.ThrowIfNull(graph);
+ return ApplyCore(graph, new LayoutOptions());
+ }
+
+ ///
+ /// Computes a placed layout for using as the
+ /// cascaded fallback for any setting the graph itself does not explicitly declare. This extension
+ /// point exists purely so a composite algorithm (for example a hierarchical algorithm)
+ /// can recurse into a nested scope with that scope's already-resolved effective options; it is not
+ /// part of the public contract, so an external caller cannot use it to supply settings that
+ /// contradict the graph's own.
+ ///
+ /// The unplaced input graph, or nested scope, to lay out.
+ ///
+ /// The cascaded fallback for any setting does not explicitly declare.
+ /// Properties this algorithm does not understand are ignored, so a caller may pass options intended
+ /// for another algorithm without error.
+ ///
+ /// A fully-placed with absolute coordinates and routed edges.
+ ///
+ /// Thrown when or is .
+ ///
+ protected internal abstract LayoutTree ApplyCore(LayoutGraph graph, LayoutOptions options);
+}
diff --git a/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmRegistry.cs b/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmRegistry.cs
index 2b40979..eb39f65 100644
--- a/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmRegistry.cs
+++ b/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmRegistry.cs
@@ -7,14 +7,14 @@
namespace DemaConsulting.Rendering.Abstractions;
///
-/// A lookup of implementations keyed by .
+/// A lookup of implementations keyed by .
/// Consumers register the algorithms they wish to make available (for example the bundled
/// layered algorithm) and resolve one by identifier at layout time. This is the ELK-style
/// algorithm-provider service; it is not thread-safe for concurrent registration.
///
public sealed class LayoutAlgorithmRegistry
{
- private readonly Dictionary _algorithms =
+ private readonly Dictionary _algorithms =
new(StringComparer.Ordinal);
/// Gets the identifiers of all registered algorithms.
@@ -26,7 +26,7 @@ public sealed class LayoutAlgorithmRegistry
/// The algorithm to register.
/// This registry, to support fluent chaining.
/// Thrown when is .
- public LayoutAlgorithmRegistry Register(ILayoutAlgorithm algorithm)
+ public LayoutAlgorithmRegistry Register(LayoutAlgorithmBase algorithm)
{
ArgumentNullException.ThrowIfNull(algorithm);
_algorithms[algorithm.Id] = algorithm;
@@ -48,7 +48,7 @@ public bool Contains(string id)
/// When this method returns , the resolved algorithm.
/// when a matching algorithm is registered; otherwise .
/// Thrown when is .
- public bool TryResolve(string id, out ILayoutAlgorithm? algorithm)
+ public bool TryResolve(string id, out LayoutAlgorithmBase? algorithm)
{
ArgumentNullException.ThrowIfNull(id);
return _algorithms.TryGetValue(id, out algorithm);
@@ -59,7 +59,7 @@ public bool TryResolve(string id, out ILayoutAlgorithm? algorithm)
/// The resolved algorithm.
/// Thrown when is .
/// Thrown when no algorithm is registered under .
- public ILayoutAlgorithm Resolve(string id)
+ public LayoutAlgorithmBase Resolve(string id)
{
ArgumentNullException.ThrowIfNull(id);
return _algorithms.TryGetValue(id, out var algorithm)
diff --git a/src/DemaConsulting.Rendering.Abstractions/NamespaceDoc.cs b/src/DemaConsulting.Rendering.Abstractions/NamespaceDoc.cs
index 10359e8..daf34fa 100644
--- a/src/DemaConsulting.Rendering.Abstractions/NamespaceDoc.cs
+++ b/src/DemaConsulting.Rendering.Abstractions/NamespaceDoc.cs
@@ -5,13 +5,13 @@
namespace DemaConsulting.Rendering.Abstractions;
///
-/// The service-provider interface (SPI) tier: the pluggable and
+/// The service-provider interface (SPI) tier: the pluggable and
/// contracts, their registries, and the that sit between
/// the diagram model and the concrete layout and render implementations.
///
///
///
-/// It defines the contract (turn an unplaced LayoutGraph into
+/// It defines the contract (turn an unplaced LayoutGraph into
/// a placed LayoutTree) and the contract (draw a LayoutTree to
/// an output format), the and
/// that resolve them by identifier, and the (with the built-in
diff --git a/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs b/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs
index 761825e..baa0131 100644
--- a/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs
+++ b/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs
@@ -65,9 +65,10 @@ public sealed record ConnectorRouteOptions(
///
/// The orchestration is deliberately model-agnostic: boxes are matched to their connections by
/// instance identity, and no domain concept (names, kinds, qualified references) enters the routing.
-/// For each connection the two endpoint boxes are excluded from the obstacle set — a connector must
-/// be free to leave and enter the boxes it joins — while every other box becomes a
-/// obstacle the route steers around by .
+/// Every box on the canvas, including a connection's own two endpoints, becomes a
+/// obstacle the route steers around by ; a connector still
+/// leaves and enters the boxes it joins cleanly because each anchor is stepped off its box with a
+/// perpendicular stub longer than the clearance before the obstacle-avoiding search begins.
///
///
/// Source and target anchors are chosen on the box faces that front each other, based on the boxes'
@@ -121,8 +122,9 @@ public static class ConnectorRouter
/// same order.
///
///
- /// All placed boxes on the canvas. Boxes other than a connection's two endpoints act as obstacles
- /// for that connection's route.
+ /// All placed boxes on the canvas, including each connection's own two endpoints. Every box acts
+ /// as a hard obstacle for the routes; a connection's own endpoints still permit a clean
+ /// leave/enter because anchors are stepped off their box before obstacle avoidance begins.
///
/// The connectors to route, each naming a source and target box.
/// Routing options, including the routing style and obstacle clearance.
@@ -195,8 +197,9 @@ public static IReadOnlyList Route(
/// Routes a single among the placed .
///
///
- /// All placed boxes on the canvas. Every box except the connection's two endpoints acts as an
- /// obstacle for this route.
+ /// All placed boxes on the canvas, including the connection's own two endpoints. Every box acts as
+ /// a hard obstacle for this route; the connection's own endpoints still permit a clean leave/enter
+ /// because anchors are stepped off their box before obstacle avoidance begins.
///
/// The connector to route.
/// Routing options, including the routing style and obstacle clearance.
@@ -244,9 +247,10 @@ public static LayoutLine Route(
}
///
- /// Builds the obstacle set for — every other box, plus any already
- /// routed treated as soft (cost-penalized, never hard-blocking)
- /// obstacles — and routes it between the already chosen .
+ /// Builds the obstacle set for — every box on the canvas, including
+ /// this connection's own two endpoints, plus any already routed
+ /// treated as soft (cost-penalized, never hard-blocking) obstacles — and routes it between the
+ /// already chosen .
///
private static LayoutLine RouteWithAnchors(
IReadOnlyList boxes,
@@ -260,16 +264,16 @@ private static LayoutLine RouteWithAnchors(
var source = ResolveAnchorPoint(from, anchors.SourceSide, anchors.SourceAlong, options.Clearance);
var target = ResolveAnchorPoint(to, anchors.TargetSide, anchors.TargetAlong, options.Clearance);
- // The hard obstacle set is every box except this connection's own endpoints, matched by
- // instance identity. The connector must be free to leave and enter the boxes it joins.
+ // The hard obstacle set is every box on the canvas, including this connection's own endpoints.
+ // The connector still leaves and enters the boxes it joins cleanly: OrthogonalEdgeRouter steps
+ // off each anchor with a perpendicular stub longer than the obstacle clearance before searching
+ // for a path, so the free-space search always starts and ends safely outside its own box's
+ // inflated obstacle footprint. Treating the endpoints as real obstacles for the *rest* of the
+ // route is what stops a path from cutting across its own target box's interior (e.g. through a
+ // compartment's content) when steering around other already-routed connectors.
var obstacles = new List(boxes.Count);
foreach (var box in boxes)
{
- if (ReferenceEquals(box, from) || ReferenceEquals(box, to))
- {
- continue;
- }
-
obstacles.Add(new Rect(box.X, box.Y, box.Width, box.Height));
}
diff --git a/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs
index e879dab..fce9280 100644
--- a/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs
+++ b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs
@@ -9,7 +9,7 @@ namespace DemaConsulting.Rendering.Layout;
///
/// The bundled containment layout algorithm: arranges the input graph's top-level nodes by packing them
/// into rows within a width budget, then routes each edge around the packed boxes with the selected
-/// style. This is a second reference
+/// style. This is a second reference
/// implementation alongside the layered algorithm; it composes the reusable
/// packer and the orchestration.
///
@@ -53,13 +53,13 @@ namespace DemaConsulting.Rendering.Layout;
/// graph.AddEdge("e2", a, c);
///
/// // Pack the nodes into rows and route the edges around them.
-/// var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions());
+/// var tree = new ContainmentLayoutAlgorithm().Apply(graph);
///
/// // Hand the placed tree to a renderer (for example the SVG renderer).
/// // new SvgRenderer().Render(tree, new RenderOptions(Themes.Light), stream);
///
///
-public sealed class ContainmentLayoutAlgorithm : ILayoutAlgorithm
+public sealed class ContainmentLayoutAlgorithm : LayoutAlgorithmBase
{
///
/// The stable algorithm identifier "containment" under which this algorithm is selected and
@@ -90,10 +90,10 @@ public sealed class ContainmentLayoutAlgorithm : ILayoutAlgorithm
private const double NodeSpacing = 24.0;
///
- public string Id => AlgorithmId;
+ public override string Id => AlgorithmId;
///
- public LayoutTree Apply(LayoutGraph graph, LayoutOptions options)
+ protected internal override LayoutTree ApplyCore(LayoutGraph graph, LayoutOptions options)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(options);
diff --git a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs
index ba08b71..e69ebe5 100644
--- a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs
+++ b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs
@@ -57,8 +57,8 @@ namespace DemaConsulting.Rendering.Layout;
/// The engine never mutates the caller's input graph. When a level must be re-sized to account for
/// nested content, it builds an internal sized view graph (same node order, container nodes
/// carrying their effective size, in-scope edges only) and lays that out, leaving the caller's graph
-/// untouched. The engine is stateless and its is safe to call concurrently on
-/// distinct graphs.
+/// untouched. The engine is stateless and its is
+/// safe to call concurrently on distinct graphs.
///
///
/// Leaf-algorithm contract. This engine builds each scope's cascaded effective
@@ -83,14 +83,14 @@ namespace DemaConsulting.Rendering.Layout;
/// graph.AddNode("outside", 80, 40); // a sibling leaf at the root
///
/// // Pack the root with the containment algorithm; containers recurse with their own algorithm.
-/// var options = LayoutOptions.ForAlgorithm("containment");
-/// var tree = new HierarchicalLayoutAlgorithm().Apply(graph, options);
+/// graph.Set(CoreOptions.Algorithm, "containment");
+/// var tree = new HierarchicalLayoutAlgorithm().Apply(graph);
///
/// // Hand the composed tree to a renderer (for example the SVG renderer).
/// // new SvgRenderer().Render(tree, new RenderOptions(Themes.Light), stream);
///
///
-public sealed class HierarchicalLayoutAlgorithm : ILayoutAlgorithm
+public sealed class HierarchicalLayoutAlgorithm : LayoutAlgorithmBase
{
///
/// The stable algorithm identifier "hierarchical" under which this algorithm is selected and
@@ -209,7 +209,7 @@ public HierarchicalLayoutAlgorithm(LayoutAlgorithmRegistry registry)
}
///
- public string Id => AlgorithmId;
+ public override string Id => AlgorithmId;
///
///
@@ -218,7 +218,7 @@ public HierarchicalLayoutAlgorithm(LayoutAlgorithmRegistry registry)
///
/// Thrown when a scope selects a layout algorithm identifier that is not registered.
///
- public LayoutTree Apply(LayoutGraph graph, LayoutOptions options)
+ protected internal override LayoutTree ApplyCore(LayoutGraph graph, LayoutOptions options)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(options);
@@ -250,7 +250,7 @@ private LayoutTree LayoutScope(LayoutGraph graph, LayoutOptions effective)
if (!anyContainer)
{
- return algo.Apply(graph, effective);
+ return algo.ApplyCore(graph, effective);
}
// Hierarchical path (SeparateChildren): recurse into each container child, size it to fit, place
@@ -277,7 +277,7 @@ private LayoutTree LayoutScope(LayoutGraph graph, LayoutOptions effective)
// 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.
- var placed = algo.Apply(view, effective);
+ var placed = algo.ApplyCore(view, effective);
var placedBoxes = placed.Nodes.OfType().ToList();
var placedLines = placed.Nodes.OfType().ToList();
var placedPorts = placed.Nodes.OfType().ToList();
diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs
index 7b6ea31..5e63e79 100644
--- a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs
+++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs
@@ -11,7 +11,7 @@ namespace DemaConsulting.Rendering.Layout;
///
/// The bundled ELK-style layered layout algorithm: arranges the input graph into Sugiyama layers and
/// routes edges orthogonally, producing a placed of boxes and connectors.
-/// This is the reference implementation; it wraps the reusable layered
+/// This is the reference implementation; it wraps the reusable layered
/// pipeline under Engine/Layered/. It honors so the layers
/// progress right, left, down, or up (a downward flow lays action-flow and state-transition diagrams
/// out top-to-bottom), so the minimum gap between nodes stacked
@@ -25,7 +25,7 @@ namespace DemaConsulting.Rendering.Layout;
/// Rendering.Abstractions, also consumed by Rendering.Svg's renderer so layout-time and
/// render-time measurements never disagree) at .
///
-public sealed class LayeredLayoutAlgorithm : ILayoutAlgorithm
+public sealed class LayeredLayoutAlgorithm : LayoutAlgorithmBase
{
///
/// The stable algorithm identifier "layered" under which this algorithm is selected and
@@ -50,10 +50,10 @@ public sealed class LayeredLayoutAlgorithm : ILayoutAlgorithm
private const double PortSideTolerance = 0.01;
///
- public string Id => AlgorithmId;
+ public override string Id => AlgorithmId;
///
- public LayoutTree Apply(LayoutGraph graph, LayoutOptions options)
+ protected internal override LayoutTree ApplyCore(LayoutGraph graph, LayoutOptions options)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(options);
diff --git a/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs b/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs
index a0434d1..6d66554 100644
--- a/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs
+++ b/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs
@@ -33,7 +33,7 @@ namespace DemaConsulting.Rendering.Layout;
/// // Assemble the bundled algorithms once, then resolve by identifier at run time.
/// var registry = LayoutAlgorithms.CreateDefaultRegistry();
/// var algorithm = registry.Resolve("containment");
-/// var tree = algorithm.Apply(graph, new LayoutOptions());
+/// var tree = algorithm.Apply(graph);
///
/// // The registry is a normal registry: extend it with a custom algorithm if needed.
/// registry.Register(new MyTreeLayoutAlgorithm());
@@ -48,7 +48,7 @@ public static class LayoutAlgorithms
/// (hierarchical).
///
///
- /// A new registry from which each bundled algorithm resolves by its .
+ /// A new registry from which each bundled algorithm resolves by its .
/// The instance is freshly allocated on every call, so callers may safely add or replace algorithms.
///
public static LayoutAlgorithmRegistry CreateDefaultRegistry() =>
diff --git a/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs b/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs
index 7a0a622..622aecc 100644
--- a/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs
+++ b/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs
@@ -45,7 +45,7 @@ namespace DemaConsulting.Rendering.Layout;
/// The single-argument overload resolves against a shared default registry of the bundled algorithms
/// (see ). Because the bundled algorithms are
/// stateless, that shared registry is safe to read concurrently. Callers that want to add or replace
-/// algorithms — for example to register a custom — pass their own
+/// algorithms — for example to register a custom — pass their own
/// registry to the two-argument overload.
///
///
@@ -140,10 +140,11 @@ public static LayoutTree Layout(LayoutGraph graph, LayoutAlgorithmRegistry regis
var algorithmId = ResolveAlgorithm(graph);
- // The graph carries every explicit setting a leaf algorithm needs; seed the cascade with an
- // empty LayoutOptions rather than accepting a caller-supplied one, so the graph itself is the
- // single place options are configured (see the "Algorithm resolution" remarks above).
- return registry.Resolve(algorithmId).Apply(graph, new LayoutOptions());
+ // The graph carries every explicit setting a leaf algorithm needs; the single-argument Apply
+ // seeds the cascade with an empty LayoutOptions rather than accepting a caller-supplied one, so
+ // the graph itself is the single place options are configured (see the "Algorithm resolution"
+ // remarks above).
+ return registry.Resolve(algorithmId).Apply(graph);
}
///
diff --git a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs
index ac89356..6ac81e5 100644
--- a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs
+++ b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs
@@ -424,7 +424,7 @@ private static void RenderNotePng(
SKPaint fillPaint,
SKPaint strokePaint)
{
- var fold = Math.Min(Math.Min(box.Width, box.Height) * NotationMetrics.NoteFoldFraction, NotationMetrics.NoteFoldMaxSize);
+ var fold = BoxMetrics.NoteFoldSize(box);
var x = (float)(box.X * scale);
var y = (float)(box.Y * scale);
@@ -529,15 +529,25 @@ private static void RenderBoxCompartments(
// as a stray line past the fold.
if (!isFirstCompartment || titleAreaOccupiesSpace)
{
+ // A Note's folded corner cuts its top-right corner short, so any full-width divider
+ // whose Y would still fall within that cutout (possible for a non-first compartment
+ // immediately following an empty leading one) must be pushed down to at least the
+ // fold's bottom edge; otherwise the line overshoots the cut and reads as a stray
+ // mark crossing the folded corner. Only the drawn line's Y is clamped here, not
+ // compartmentY itself, so row text position is unaffected.
+ var dividerY = box.Shape == BoxShape.Note
+ ? Math.Max(compartmentY, box.Y + BoxMetrics.NoteFoldSize(box))
+ : compartmentY;
+
using var divPaint = new SKPaint();
divPaint.Color = strokeColor;
divPaint.Style = SKPaintStyle.Stroke;
divPaint.StrokeWidth = (float)theme.StrokeWidth * scale;
canvas.DrawLine(
(float)(box.X * scale),
- (float)(compartmentY * scale),
+ (float)(dividerY * scale),
(float)((box.X + box.Width) * scale),
- (float)(compartmentY * scale),
+ (float)(dividerY * scale),
divPaint);
}
diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs
index b438256..4ffdb47 100644
--- a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs
+++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs
@@ -493,8 +493,7 @@ private static double ResolveTitleAreaTop(LayoutBox box, Theme theme) =>
///
private static void RenderNoteOutline(StringBuilder sb, LayoutBox box, Theme theme, string fillColor, double scale)
{
- var fold = Math.Min(box.Width, box.Height) * NotationMetrics.NoteFoldFraction;
- fold = Math.Min(fold, NotationMetrics.NoteFoldMaxSize);
+ var fold = BoxMetrics.NoteFoldSize(box);
var x = box.X * scale;
var y = box.Y * scale;
@@ -618,9 +617,19 @@ private static void RenderBoxCompartments(StringBuilder sb, LayoutBox box, Theme
// as a stray line past the fold.
if (!isFirstCompartment || titleAreaOccupiesSpace)
{
+ // A Note's folded corner cuts its top-right corner short, so any full-width divider
+ // whose Y would still fall within that cutout (possible for a non-first compartment
+ // immediately following an empty leading one) must be pushed down to at least the
+ // fold's bottom edge; otherwise the line overshoots the cut and reads as a stray
+ // mark crossing the folded corner. Only the drawn line's Y is clamped here, not
+ // compartmentY itself, so row text position is unaffected.
+ var dividerY = box.Shape == BoxShape.Note
+ ? Math.Max(compartmentY, box.Y + BoxMetrics.NoteFoldSize(box))
+ : compartmentY;
+
// Full-width horizontal divider at the top of this compartment
sb.Append(CultureInfo.InvariantCulture,
- $""" """);
+ $""" """);
sb.AppendLine();
}
diff --git a/src/DemaConsulting.Rendering/NamespaceDoc.cs b/src/DemaConsulting.Rendering/NamespaceDoc.cs
index 01258d0..c284b75 100644
--- a/src/DemaConsulting.Rendering/NamespaceDoc.cs
+++ b/src/DemaConsulting.Rendering/NamespaceDoc.cs
@@ -39,7 +39,7 @@ namespace DemaConsulting.Rendering;
///
/// Lay it out with the DemaConsulting.Rendering.Layout package: the one-call
/// LayoutEngine.Layout(graph) facade resolves whatever algorithm the graph
-/// declares, or select a specific ILayoutAlgorithm (layered, containment,
+/// declares, or select a specific LayoutAlgorithmBase (layered, containment,
/// or the recursive hierarchical engine) yourself. This yields a placed
/// .
///
@@ -53,7 +53,7 @@ namespace DemaConsulting.Rendering;
///
///
///
-/// The DemaConsulting.Rendering.Abstractions package defines the ILayoutAlgorithm and
+/// The DemaConsulting.Rendering.Abstractions package defines the LayoutAlgorithmBase and
/// IRenderer service-provider contracts, their registries, and the Theme that styles
/// output. The dependency pipeline is model ← Abstractions ← Layout ←
/// Svg/Skia, so this model package depends on nothing and is the natural place to begin reading.
@@ -61,9 +61,9 @@ namespace DemaConsulting.Rendering;
///
/// Configuration is open and property-based: algorithms and
/// renderers read only the properties they understand, so unknown or not-yet-honoured properties
-/// default harmlessly. New diagram families and output formats are added additively by implementing
-/// ILayoutAlgorithm or IRenderer and registering them — no existing contract changes.
-/// The public surface deliberately mirrors the
+/// default harmlessly. New diagram families and output formats are added additively by deriving from
+/// LayoutAlgorithmBase or implementing IRenderer and registering them — no existing
+/// contract changes. The public surface deliberately mirrors the
/// Eclipse Layout Kernel (ELK) so ELK users are
/// immediately at home.
///
diff --git a/test/DemaConsulting.Rendering.Abstractions.Tests/RegistryTests.cs b/test/DemaConsulting.Rendering.Abstractions.Tests/RegistryTests.cs
index 0a79184..ffe5c10 100644
--- a/test/DemaConsulting.Rendering.Abstractions.Tests/RegistryTests.cs
+++ b/test/DemaConsulting.Rendering.Abstractions.Tests/RegistryTests.cs
@@ -14,8 +14,8 @@ public class RegistryTests
{
///
/// Proves that a registered algorithm can be found by identifier and that resolving it returns
- /// an instance whose can be invoked through the contract to
- /// produce a placed layout tree.
+ /// an instance whose can be invoked through
+ /// the contract to produce a placed layout tree.
///
[Fact]
public void LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm()
@@ -26,7 +26,7 @@ public void LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm()
// Act
var algorithm = registry.Resolve("fake");
- var tree = algorithm.Apply(new LayoutGraph(), new LayoutOptions());
+ var tree = algorithm.Apply(new LayoutGraph());
// Assert: the algorithm is resolved by identifier and its Apply contract method can be invoked,
// producing the placed layout tree it was implemented to return.
@@ -93,14 +93,14 @@ public void RendererRegistry_ResolveByExtension_MatchesAdvertisedExtensions()
Assert.Equal("text/plain", registry.ResolveByExtension("TEXT").MediaType);
}
- private sealed class FakeAlgorithm : ILayoutAlgorithm
+ private sealed class FakeAlgorithm : LayoutAlgorithmBase
{
public const double PlacedWidth = 42.0;
public const double PlacedHeight = 24.0;
- public string Id => "fake";
+ public override string Id => "fake";
- public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) => new(PlacedWidth, PlacedHeight, []);
+ protected internal override LayoutTree ApplyCore(LayoutGraph graph, LayoutOptions options) => new(PlacedWidth, PlacedHeight, []);
}
private sealed class FakeRenderer : IRenderer
diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs
index adb300b..1a4ee5e 100644
--- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs
+++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs
@@ -33,6 +33,7 @@ internal static class GalleryCatalog
// single edit that keeps the committed artifacts and the index in lockstep.
public const string LayeredPipelineSvg = "layered-pipeline.svg";
public const string IsolatedNodeLayerGapSvg = "isolated-node-layer-gap.svg";
+ public const string ParallelEdgesIntoCompartmentBoxSvg = "parallel-edges-into-compartment-box.svg";
public const string ContainmentPackedSvg = "containment-packed.svg";
public const string HierarchicalNestedSvg = "hierarchical-nested.svg";
public const string OrthogonalObstacleSvg = "orthogonal-obstacle.svg";
@@ -106,6 +107,15 @@ internal static class GalleryCatalog
+ "clusters isolated nodes at the end of the layer's order, and coordinate "
+ "assignment squeezes any resulting gap down to the standard node spacing, instead "
+ "of inheriting the dummy's unrelated port-alignment floor as an inflated gap."),
+ new GalleryImage(
+ ParallelEdgesIntoCompartmentBoxSvg,
+ "Nine edges docking on a compartment box without crossing its interior",
+ "Regression coverage for the parallel-edges-into-compartment-box fix: nine "
+ + "unmerged edges from a small Source box converge on a taller Target box's "
+ + "nine-row compartment. ConnectorRouter now treats every box, including a "
+ + "connection's own endpoints, as a hard obstacle for the whole route (not just "
+ + "the final docking stub), so a connector squeezed by other already-routed "
+ + "connectors can no longer detour straight through its own target box's interior."),
]),
new GallerySection(
"Flow direction",
diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs
index a42c9d7..4ed480e 100644
--- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs
+++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs
@@ -135,6 +135,47 @@ public static LayoutGraph ContainmentPacked()
return graph;
}
+ ///
+ /// Regression coverage (and visual evidence) for the parallel-edges-into-compartment-box fix: a
+ /// small source box and a much taller target box with a nine-row compartment, joined by nine
+ /// distinct unmerged edges (mirroring set to
+ /// ). Before the fix, ConnectorRouter excluded a connection's own
+ /// endpoint boxes from the hard-obstacle set for its entire route rather than just its
+ /// final docking stub, so a later connector squeezed by earlier already-routed connectors' soft
+ /// obstacles could detour straight through its own target box's interior — cutting across the
+ /// compartment's row text — before curving back out to its real anchor. After the fix, every box
+ /// (including a connection's own endpoints) is a hard obstacle for the rest of the path.
+ ///
+ ///
+ /// A flat graph suited to the containment algorithm: a small Source box and a taller
+ /// Target box carrying a nine-row compartment, joined by nine distinct edges.
+ ///
+ public static LayoutGraph ParallelEdgesIntoCompartmentBox()
+ {
+ var graph = new LayoutGraph();
+ graph.Set(CoreOptions.MergeParallelEdges, false);
+
+ var source = graph.AddNode("source", 130, 50);
+ source.Label = "Source";
+
+ var target = graph.AddNode("target", 130, 242);
+ target.Label = "Target";
+ target.Compartments =
+ [
+ new LayoutCompartment(
+ "rows",
+ ["row1", "row2", "row3", "row4", "row5", "row6", "row7", "row8", "row9"]),
+ ];
+
+ for (var i = 0; i < 9; i++)
+ {
+ var edge = graph.AddEdge($"source-target-{i}", source, target);
+ edge.TargetEnd = EndMarkerStyle.FilledDiamond;
+ }
+
+ return graph;
+ }
+
///
/// A container node holding a nested child graph, plus an external leaf joined to a child by a
/// cross-container edge — the canonical hierarchical (nested) diagram.
diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs
index 9254312..e752f67 100644
--- a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs
+++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs
@@ -49,6 +49,23 @@ public void Gallery_IsolatedNodeLayerGap_RendersSvg()
Themes.Dark);
}
+ ///
+ /// Renders the parallel-edges-into-compartment-box regression diagram to SVG, proving
+ /// ConnectorRouter keeps nine parallel connectors converging on a compartment box outside
+ /// its interior instead of cutting across its rows.
+ ///
+ [Fact]
+ public void Gallery_ParallelEdgesIntoCompartmentBox_RendersSvg()
+ {
+ var graph = GalleryDiagrams.ParallelEdgesIntoCompartmentBox();
+ graph.Set(CoreOptions.Algorithm, "containment");
+
+ GalleryWriter.Svg(
+ GalleryCatalog.ParallelEdgesIntoCompartmentBoxSvg,
+ graph,
+ Themes.Dark);
+ }
+
///
/// Renders the containment-packed diagram to SVG, proving the containment algorithm packs
/// sibling boxes into a valid document.
diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs
index 455bbe3..15bd6db 100644
--- a/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs
+++ b/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs
@@ -379,17 +379,18 @@ public void Route_ObstacleBetweenEndpoints_RoutesAroundInterior()
}
///
- /// The two endpoint boxes are excluded from the obstacle set: the connector reaches their
- /// boundary anchors even though both boxes are present in the box list. If they were treated
- /// as obstacles, no clean approach to their faces would exist.
+ /// Both endpoint boxes are ordinary obstacles, yet the connector still reaches their boundary
+ /// anchors cleanly: the underlying orthogonal edge router steps each anchor off its face with a
+ /// perpendicular approach stub that clears the endpoint before pathfinding, then reattaches the
+ /// true anchor when assembling the final path.
///
[Fact]
- public void Route_EndpointBoxes_AreExcludedFromObstacles()
+ public void Route_EndpointBoxes_ReachBoundaryAnchorsViaApproachStub()
{
// Arrange: two large adjacent boxes with only a narrow gap between their facing edges. The
// source anchor sits on the source's right face and the target anchor on the target's left
- // face; both anchors lie on box boundaries that only route cleanly when the endpoints are
- // excluded from the obstacle set.
+ // face; both anchors lie on box boundaries that only route cleanly via each endpoint's
+ // approach stub.
var from = Box(0, 0, 120, 120);
var to = Box(140, 0, 120, 120);
var boxes = new[] { from, to };
@@ -397,8 +398,7 @@ public void Route_EndpointBoxes_AreExcludedFromObstacles()
// Act
var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions());
- // Assert: a clean, straight orthogonal hop between the facing faces (only possible because
- // neither endpoint box is an obstacle)
+ // Assert: a clean, straight orthogonal hop between the facing faces
AssertAllSegmentsOrthogonal(line.Waypoints);
Assert.Equal(from.X + from.Width, line.Waypoints[0].X, 6);
Assert.Equal(to.X, line.Waypoints[^1].X, 6);
@@ -407,6 +407,7 @@ public void Route_EndpointBoxes_AreExcludedFromObstacles()
Assert.DoesNotContain(line.Waypoints, p => IsStrictlyInside(p, from) || IsStrictlyInside(p, to));
}
+
///
/// The routed carries the connection's requested target end marker,
/// line style, and label, and always reports no source-end marker.
@@ -715,6 +716,45 @@ public void Route_ThreeSourceBoxesNarrowGap_InteriorCorridorsDoNotOverlapAlongLe
AssertNoCollinearOverlapAcrossLines(lines);
}
+ ///
+ /// Reproduces the SysML2Tools rendering defect: nine parallel, unmerged edges from a small
+ /// source box converge on the left face of a much taller multi-row-compartment target box.
+ /// Before the fix, routing each connection excluded both connection endpoints from the
+ /// hard-obstacle set for the whole path (not just the immediate approach stub), so a later
+ /// connector squeezed by earlier already-routed connectors' soft obstacles was free to detour
+ /// straight through its own target box's interior — visually cutting across the compartment's
+ /// row text — before curving back out to its real anchor. After the fix, every box (including a
+ /// connection's own endpoints) is a hard obstacle for the rest of the path; only the short
+ /// perpendicular docking stub the underlying router already adds is exempt.
+ ///
+ [Fact]
+ public void Route_NineParallelEdgesIntoCompartmentBox_DoNotCrossTargetInterior()
+ {
+ // Arrange: geometry mirroring the reported bug — a small port box to the left of a much taller
+ // box with a nine-row ports compartment, joined by nine distinct unmerged edges (mirroring
+ // CoreOptions.MergeParallelEdges = false).
+ var eePort = Box(12, 12, 130, 50, "EEPort");
+ var lbo = Box(166, 12, 130, 242, "LBO");
+ var boxes = new[] { eePort, lbo };
+
+ var connections = Enumerable.Range(0, 9)
+ .Select(_ => new Connection(eePort, lbo, EndMarkerStyle.FilledDiamond))
+ .ToArray();
+
+ // Act
+ var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions());
+
+ // Assert: every routed connector reaches LBO's face without any segment cutting through
+ // either box's own strict interior (including its own endpoints).
+ var lboRect = new Rect(lbo.X, lbo.Y, lbo.Width, lbo.Height);
+ var eePortRect = new Rect(eePort.X, eePort.Y, eePort.Width, eePort.Height);
+ Assert.All(lines, line =>
+ {
+ AssertNoSegmentCrossesObstacle(line.Waypoints, lboRect);
+ AssertNoSegmentCrossesObstacle(line.Waypoints, eePortRect);
+ });
+ }
+
///
/// Asserts that no two of the given lines' interior segments — those excluding each
/// line's first and last segment, which are the endpoint-adjacent approach legs the router's own
diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs
index f49d62f..72b7eba 100644
--- a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs
+++ b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs
@@ -43,7 +43,7 @@ public void Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPackedBox()
node.FolderTabHeight = 24.0;
// Act
- var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert
var box = Assert.Single(tree.Nodes.OfType());
@@ -74,7 +74,7 @@ public void Apply_Graph_PacksNodesNonOverlappingInInputOrderWithinCanvas()
d.Label = "D";
// Act
- var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: one box per node, emitted in input order
var boxes = tree.Nodes.OfType().ToList();
@@ -120,7 +120,7 @@ public void Apply_Graph_RoutesOneConnectorPerEdgeCarryingStyling()
e2.TargetEnd = EndMarkerStyle.HollowDiamond;
// Act
- var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: one line per edge, each carrying its styling and a routed polyline
var lines = tree.Nodes.OfType().ToList();
@@ -151,7 +151,7 @@ public void Apply_EdgeCrossingInterveningBox_RoutesAroundIt()
graph.AddEdge("e", a, b);
// Act
- var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: the three boxes packed onto one row, with the middle box between the endpoints
var boxes = tree.Nodes.OfType().ToList();
@@ -180,7 +180,7 @@ public void Apply_EdgeCrossingInterveningBox_RoutesAroundIt()
public void Apply_EmptyGraph_ReturnsEmptyCanvas()
{
// Act
- var tree = new ContainmentLayoutAlgorithm().Apply(new LayoutGraph(), new LayoutOptions());
+ var tree = new ContainmentLayoutAlgorithm().ApplyCore(new LayoutGraph(), new LayoutOptions());
// Assert
Assert.Empty(tree.Nodes);
@@ -206,7 +206,7 @@ public void Apply_EdgeReferencingOutOfGraphNode_IsSkipped()
graph.AddEdge("out", a, inner);
// Act
- var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: only the in-graph edge is routed; the out-of-graph edge is skipped
var lines = tree.Nodes.OfType().ToList();
@@ -221,7 +221,7 @@ public void Apply_NullGraph_Throws()
{
// Act / Assert
Assert.Throws(
- () => new ContainmentLayoutAlgorithm().Apply(null!, new LayoutOptions()));
+ () => new ContainmentLayoutAlgorithm().ApplyCore(null!, new LayoutOptions()));
}
///
@@ -232,7 +232,7 @@ public void Apply_NullOptions_Throws()
{
// Act / Assert
Assert.Throws(
- () => new ContainmentLayoutAlgorithm().Apply(new LayoutGraph(), null!));
+ () => new ContainmentLayoutAlgorithm().ApplyCore(new LayoutGraph(), null!));
}
///
@@ -260,7 +260,7 @@ public void Apply_EdgeRoutingOverrideOnGraphScope_IsHonored()
graph.Set(CoreOptions.EdgeRouting, EdgeRouting.Orthogonal);
// Act
- var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: the edge is still routed using the graph's own (orthogonal) style.
var line = Assert.Single(tree.Nodes.OfType());
diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs
index 4010ed1..e6ebd2e 100644
--- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs
+++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs
@@ -3,6 +3,7 @@
//
using DemaConsulting.Rendering;
+using DemaConsulting.Rendering.Abstractions;
using DemaConsulting.Rendering.Layout.Engine.Layered;
namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered;
@@ -10,7 +11,7 @@ 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
+/// 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.
@@ -54,7 +55,7 @@ public void MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonal
controller.Children.AddEdge("command-driver", command, driver);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: exactly one shared anchor, and every edge reaching it is orthogonal along its whole path.
AssertEveryEdgeAtSharedAnchorIsOrthogonal(tree, expectedReachingAnchor: 3);
@@ -95,7 +96,7 @@ public void MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonal
controller.Children.AddEdge("command-logger", command, logger);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: exactly one shared anchor, and every edge reaching it is orthogonal along its whole path.
AssertEveryEdgeAtSharedAnchorIsOrthogonal(tree, expectedReachingAnchor: 4);
@@ -137,7 +138,7 @@ public void MergeRegionDecomposer_InternalFanOut_DelegationEdges_TakeMinimalBend
toLogger.Label = "to-logger";
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(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.
diff --git a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs
index 941fbbc..cf25724 100644
--- a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs
+++ b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs
@@ -41,8 +41,8 @@ public void Apply_FlatRandomGraphs_MatchLayeredAlgorithmExactly()
var options = LayoutOptions.ForAlgorithm("layered");
// Act
- var expected = new LayeredLayoutAlgorithm().Apply(graph, options);
- var actual = new HierarchicalLayoutAlgorithm().Apply(graph, options);
+ var expected = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
+ var actual = new HierarchicalLayoutAlgorithm().ApplyCore(graph, options);
// Assert
AssertTreesIdentical($"layered seed {seed}", expected, actual);
@@ -64,8 +64,8 @@ public void Apply_FlatRandomGraphs_MatchContainmentAlgorithmExactly()
var options = LayoutOptions.ForAlgorithm("containment");
// Act
- var expected = new ContainmentLayoutAlgorithm().Apply(graph, options);
- var actual = new HierarchicalLayoutAlgorithm().Apply(graph, options);
+ var expected = new ContainmentLayoutAlgorithm().ApplyCore(graph, options);
+ var actual = new HierarchicalLayoutAlgorithm().ApplyCore(graph, options);
// Assert
AssertTreesIdentical($"containment seed {seed}", expected, actual);
@@ -91,7 +91,7 @@ public void Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely()
graph.AddNode("outside", 80, 40);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: the container box is present and carries nested boxes
var boxes = tree.Nodes.OfType().ToList();
@@ -140,8 +140,8 @@ public void Apply_ContainerWithTitleHeightOverride_ReplacesDefaultTitleBand()
overrideGroup.Children.AddNode("child", 80, 40);
// Act
- var defaultTree = new HierarchicalLayoutAlgorithm().Apply(defaultGraph, LayoutOptions.ForAlgorithm("layered"));
- var overrideTree = new HierarchicalLayoutAlgorithm().Apply(overrideGraph, LayoutOptions.ForAlgorithm("layered"));
+ var defaultTree = new HierarchicalLayoutAlgorithm().ApplyCore(defaultGraph, LayoutOptions.ForAlgorithm("layered"));
+ var overrideTree = new HierarchicalLayoutAlgorithm().ApplyCore(overrideGraph, LayoutOptions.ForAlgorithm("layered"));
// Assert: the override container is exactly (100 - 24) taller than the default container, and
// its nested child is offset down by that same difference.
@@ -176,8 +176,8 @@ public void Apply_UnlabelledContainerWithTitleHeightOverride_ReservesNoTitleBand
overrideGroup.Children.AddNode("child", 80, 40);
// Act
- var plainTree = new HierarchicalLayoutAlgorithm().Apply(plainGraph, LayoutOptions.ForAlgorithm("layered"));
- var overrideTree = new HierarchicalLayoutAlgorithm().Apply(overrideGraph, LayoutOptions.ForAlgorithm("layered"));
+ var plainTree = new HierarchicalLayoutAlgorithm().ApplyCore(plainGraph, LayoutOptions.ForAlgorithm("layered"));
+ var overrideTree = new HierarchicalLayoutAlgorithm().ApplyCore(overrideGraph, LayoutOptions.ForAlgorithm("layered"));
// Assert: both containers are sized identically, since neither reserves a title band.
var plainContainer = Assert.Single(plainTree.Nodes.OfType());
@@ -208,8 +208,8 @@ public void Apply_FolderContainer_ReservesTabHeightAboveChildren()
folderGroup.Children.AddNode("child", 80, 40);
// Act
- var rectangleTree = new HierarchicalLayoutAlgorithm().Apply(rectangleGraph, LayoutOptions.ForAlgorithm("layered"));
- var folderTree = new HierarchicalLayoutAlgorithm().Apply(folderGraph, LayoutOptions.ForAlgorithm("layered"));
+ var rectangleTree = new HierarchicalLayoutAlgorithm().ApplyCore(rectangleGraph, LayoutOptions.ForAlgorithm("layered"));
+ var folderTree = new HierarchicalLayoutAlgorithm().ApplyCore(folderGraph, LayoutOptions.ForAlgorithm("layered"));
// Assert: the folder container is exactly tab-height taller, and its child is offset down by
// that same additional amount, so the child never overlaps the recessed title area.
@@ -243,7 +243,7 @@ public void Apply_ContainmentRootWithLayeredContainer_Composes()
graph.AddNode("peer", 80, 40);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("containment"));
// Assert: the container was composed with nested children
var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0);
@@ -269,7 +269,7 @@ public void Apply_LayeredRootWithContainmentContainer_Composes()
graph.AddNode("peer", 80, 40);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert
var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0);
@@ -300,7 +300,7 @@ public void Apply_ContainerWithDirectionOverride_HonorsNestedDirection()
graph.AddNode("outside", 80, 40);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: the nested chain stacks vertically (strictly increasing Y), proving the container's own
// Down override was honored rather than falling back to the top-level Right default.
@@ -332,7 +332,7 @@ public void Apply_ThreeLevelDirectionCascade_InheritsThroughUnsetMiddleLevel()
level2.Children.AddEdge("b-c", leafB, leafC);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: level3 leaves stack vertically, proving Down cascaded through the unset level2 scope.
var outerBox = Assert.Single(tree.Nodes.OfType());
@@ -365,7 +365,7 @@ public void Apply_ThreeLevelDirectionCascade_MidLevelOverrideTakesPrecedence()
level2.Children.AddEdge("b-c", leafB, leafC);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: level3 leaves flow horizontally, not vertically, proving level2's own Right override
// took precedence over level1's inherited Down.
@@ -413,7 +413,7 @@ public void Apply_ThreeLevelEdgeRoutingCascade_ReachesEveryLeafAlgorithmCall()
level2.Children.AddEdge("a-b", leafA, leafB);
// Act
- _ = new HierarchicalLayoutAlgorithm(registry).Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ _ = new HierarchicalLayoutAlgorithm(registry).ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: the leaf-scope call (level2's own children graph, the flat fast path) received the
// EdgeRouting override cascaded down from level1, and its own Marker override, not level1's.
@@ -448,7 +448,7 @@ public void Apply_CrossContainerEdge_HonorsScopeEdgeRoutingOverride()
graph.AddEdge("cross", aChild, bChild);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("containment"));
// Assert: the cross-container edge is still routed (the scope's own EdgeRouting override,
// cascaded via the effective snapshot, was consulted rather than being silently ignored).
@@ -476,7 +476,7 @@ public void Apply_CrossContainerEdge_RoutesAroundInterveningContainer()
graph.AddEdge("cross", aChild, bChild);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("containment"));
// Assert: exactly one routed line exists at the root (the cross-container edge)
var line = Assert.Single(tree.Nodes.OfType());
@@ -519,7 +519,7 @@ public void Apply_CrossContainerEdge_ToWideContainer_DoesNotCrossEndpointBoxes()
graph.AddEdge("client-api", client, api);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: the single top-level cross-container line does not pass through the interior of either
// endpoint container box (the wide container or the leaf).
@@ -576,7 +576,7 @@ public void Apply_BoxWithBothLeafAndCrossContainerEdges_SpreadsSharedFaceAnchors
// Act: the "containment" leaf algorithm packs boxes deterministically and routes its own edges
// with ConnectorRouter, so the root scope's leaf-routed batch and its cross-container batch are
// both driven by the same underlying router — exactly like the real diagram's regression.
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("containment"));
// Assert: the two connectors landing on "target" reach distinct anchor points.
var lines = tree.Nodes.OfType().ToList();
@@ -612,7 +612,7 @@ public void Apply_SameScopePortEdge_WithUnrelatedContainerElsewhere_RoutesLikePo
unrelated.Children.AddNode("unrelated-child", 60, 40);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: exactly one LayoutPort is emitted, carrying the port's external label, and exactly
// one LayoutLine connects the two boxes — the connector is routed, not dropped, even though
@@ -659,7 +659,7 @@ public void Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorC
b.Children.AddEdge("b-to-c", port, c);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: exactly one LayoutPort, carrying BOTH labels, on B's boundary.
var emitted = Assert.Single(tree.Nodes.OfType());
@@ -719,7 +719,7 @@ public void Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor()
b.Children.AddEdge("b-c2", port, c2);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: exactly one LayoutPort carrying both labels despite the fan-out.
var emitted = Assert.Single(tree.Nodes.OfType());
@@ -769,7 +769,7 @@ public void Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors()
b.Children.AddEdge("q-c2", q, c2);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: two distinct anchors, each carrying its own labels.
var emitted = tree.Nodes.OfType().ToList();
@@ -826,7 +826,7 @@ public void Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_Preserv
b.Children.AddEdge("q-c2", q, c2);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
AssertConnectorProvenancePreserved(tree, "A1", "C1", "A2", "C2");
}
@@ -866,7 +866,7 @@ public void Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_Preserve
b.Children.AddEdge("q-c2", q, c2);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
AssertConnectorProvenancePreserved(tree, "A1", "C1", "A2", "C2");
}
@@ -972,7 +972,7 @@ public void Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws()
// Act / Assert
var ex = Assert.Throws(
- () => new HierarchicalLayoutAlgorithm().Apply(graph, new LayoutOptions()));
+ () => new HierarchicalLayoutAlgorithm().ApplyCore(graph, new LayoutOptions()));
Assert.Contains("container boundary", ex.Message, StringComparison.Ordinal);
}
@@ -1003,7 +1003,7 @@ public void Apply_NestedContainerPortEdge_TranslatesPortIntoAncestorCoordinates(
sibling.Children.AddNode("sibling-child", 60, 40);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: the "group" container box holds the emitted LayoutPort among its (translated)
// children, positioned within the composed container box's absolute bounds.
@@ -1063,7 +1063,7 @@ public void Apply_NestedGraph_PropagatesContainerAndLeafShapeKeywordCompartments
engine.RoundedCornerRadius = 14.0;
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert
var folder = Assert.Single(tree.Nodes.OfType());
@@ -1092,7 +1092,7 @@ public void Apply_ThreeLevelNesting_Succeeds()
b.Children.AddNode("leaf", 80, 40);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: the nesting depth is reflected in the composed box tree
var outer = Assert.Single(tree.Nodes.OfType());
@@ -1120,7 +1120,7 @@ public void Apply_CompoundGraph_DoesNotMutateInputNodeSizes()
var outside = graph.AddNode("outside", 64, 48);
// Act
- _ = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ _ = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: every original node keeps its exact input dimensions (bit-for-bit)
AssertExact("group.Width", 10, group.Width);
@@ -1141,7 +1141,7 @@ public void Apply_CompoundGraph_DoesNotMutateInputNodeSizes()
public void Apply_EmptyGraph_ReturnsEmptyCanvas()
{
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(new LayoutGraph(), LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(new LayoutGraph(), LayoutOptions.ForAlgorithm("layered"));
// Assert
Assert.Empty(tree.Nodes);
@@ -1164,8 +1164,8 @@ public void Apply_ContainerWithEmptyChildren_TreatedAsLeaf()
var options = LayoutOptions.ForAlgorithm("layered");
// Act
- var expected = new LayeredLayoutAlgorithm().Apply(graph, options);
- var actual = new HierarchicalLayoutAlgorithm().Apply(graph, options);
+ var expected = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
+ var actual = new HierarchicalLayoutAlgorithm().ApplyCore(graph, options);
// Assert: identical to the layered algorithm, and the single box carries no children
AssertTreesIdentical("empty-children", expected, actual);
@@ -1181,7 +1181,7 @@ public void Apply_NullGraph_Throws()
{
// Act / Assert
Assert.Throws(
- () => new HierarchicalLayoutAlgorithm().Apply(null!, new LayoutOptions()));
+ () => new HierarchicalLayoutAlgorithm().ApplyCore(null!, new LayoutOptions()));
}
///
@@ -1192,7 +1192,7 @@ public void Apply_NullOptions_Throws()
{
// Act / Assert
Assert.Throws(
- () => new HierarchicalLayoutAlgorithm().Apply(new LayoutGraph(), null!));
+ () => new HierarchicalLayoutAlgorithm().ApplyCore(new LayoutGraph(), null!));
}
///
@@ -1220,9 +1220,9 @@ public void Apply_ExplicitHierarchicalOptions_MatchesDefaultLeafExactly()
// Act
var explicitHierarchical = new HierarchicalLayoutAlgorithm()
- .Apply(graph, LayoutOptions.ForAlgorithm(HierarchicalLayoutAlgorithm.AlgorithmId));
+ .ApplyCore(graph, LayoutOptions.ForAlgorithm(HierarchicalLayoutAlgorithm.AlgorithmId));
var layered = new LayeredLayoutAlgorithm()
- .Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ .ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: explicit "hierarchical" resolves to the layered leaf, bit-for-bit
AssertTreesIdentical($"explicit-hierarchical seed {seed}", layered, explicitHierarchical);
@@ -1246,7 +1246,7 @@ public void Apply_ContainerNodeSetHierarchical_Composes()
graph.AddNode("peer", 80, 40);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: the container was composed with its two nested children
var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0);
@@ -1268,7 +1268,7 @@ public void Apply_GraphSetHierarchical_DoesNotThrow()
graph.AddNode("peer", 80, 40);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: the container was composed with its nested child
var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0);
@@ -1290,7 +1290,7 @@ public void HierarchicalLayoutAlgorithm_ThreeLevelDelegationChain_EndToEnd_Produ
var graph = BuildDelegationChain(leafWidth: 120, leafHeight: 50);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(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();
@@ -1337,7 +1337,7 @@ public void HierarchicalLayoutAlgorithm_NoBoundaryPortHierarchy_OutputIsByteIden
var options = LayoutOptions.ForAlgorithm("layered");
// Act
- var actual = new HierarchicalLayoutAlgorithm().Apply(graph, options);
+ var actual = new HierarchicalLayoutAlgorithm().ApplyCore(graph, options);
// Assert: the layout matches the captured leaf-path golden snapshot exactly (bit level).
Assert.Equal(NoBoundaryPortGolden, DumpTree(actual));
@@ -1359,7 +1359,7 @@ public void HierarchicalLayoutAlgorithm_ThreeLevelChain_InnermostContainerGrows_
var graph = BuildDelegationChain(leafWidth, leafHeight);
// Act
- var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered"));
+ var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered"));
// Assert: the innermost leaf keeps its intrinsic size.
var l1 = tree.Nodes.OfType().Single(box => box.Label == "L1");
@@ -1706,24 +1706,25 @@ private static bool SegmentCrossesRect(Point2D a, Point2D b, Rect r)
///
/// A leaf-algorithm test double that records the exact and
- /// instance every call receives, then delegates
- /// to a real inner algorithm so the composed layout remains valid. It advertises the inner
- /// algorithm's own so it can be registered in its place, transparently observing
- /// every scope places with that algorithm identifier.
+ /// instance every call receives, then
+ /// delegates to a real inner algorithm so the composed layout remains valid. It advertises the
+ /// inner algorithm's own so it can be registered in its place, transparently
+ /// observing every scope places with that algorithm
+ /// identifier.
///
- private sealed class RecordingLayoutAlgorithm(ILayoutAlgorithm inner) : ILayoutAlgorithm
+ private sealed class RecordingLayoutAlgorithm(LayoutAlgorithmBase inner) : LayoutAlgorithmBase
{
- /// The graph/options pair captured from every invocation, in call order.
+ /// The graph/options pair captured from every invocation, in call order.
public List<(LayoutGraph Graph, LayoutOptions Options)> Calls { get; } = [];
///
- public string Id => inner.Id;
+ public override string Id => inner.Id;
///
- public LayoutTree Apply(LayoutGraph graph, LayoutOptions options)
+ protected internal override LayoutTree ApplyCore(LayoutGraph graph, LayoutOptions options)
{
Calls.Add((graph, options));
- return inner.Apply(graph, options);
+ return inner.ApplyCore(graph, options);
}
}
}
diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs
index ef934fe..96b55a9 100644
--- a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs
+++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs
@@ -37,7 +37,7 @@ public void Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges()
graph.AddEdge("e1", a, b);
graph.AddEdge("e2", b, c);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var boxes = tree.Nodes.OfType().ToList();
var lines = tree.Nodes.OfType().ToList();
@@ -74,7 +74,7 @@ public void Apply_NodeWithShapeKeywordAndCompartments_PropagatesToPlacedBox()
node.FolderTabWidth = 70.0;
node.FolderTabHeight = 22.0;
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var box = Assert.Single(tree.Nodes.OfType());
Assert.Equal(BoxShape.RoundedRectangle, box.Shape);
@@ -96,7 +96,7 @@ public void Apply_NodeWithNoShapeKeywordOrCompartments_DefaultsToPlainRectangle(
var graph = new LayoutGraph();
graph.AddNode("plain", 80, 40);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var box = Assert.Single(tree.Nodes.OfType());
Assert.Equal(BoxShape.Rectangle, box.Shape);
@@ -110,7 +110,7 @@ public void Apply_NodeWithNoShapeKeywordOrCompartments_DefaultsToPlainRectangle(
[Fact]
public void Apply_EmptyGraph_ReturnsEmptyCanvas()
{
- var tree = new LayeredLayoutAlgorithm().Apply(new LayoutGraph(), new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(new LayoutGraph(), new LayoutOptions());
Assert.Empty(tree.Nodes);
Assert.True(tree.Width > 0);
@@ -124,7 +124,7 @@ public void Apply_EmptyGraph_ReturnsEmptyCanvas()
public void Apply_NullGraph_Throws()
{
Assert.Throws(
- () => new LayeredLayoutAlgorithm().Apply(null!, new LayoutOptions()));
+ () => new LayeredLayoutAlgorithm().ApplyCore(null!, new LayoutOptions()));
}
///
@@ -134,7 +134,7 @@ public void Apply_NullGraph_Throws()
public void Apply_NullOptions_Throws()
{
Assert.Throws(
- () => new LayeredLayoutAlgorithm().Apply(new LayoutGraph(), null!));
+ () => new LayeredLayoutAlgorithm().ApplyCore(new LayoutGraph(), null!));
}
///
@@ -155,7 +155,7 @@ public void Apply_DownDirection_FlowsTopToBottom()
var options = new LayoutOptions();
options.Set(CoreOptions.Direction, LayoutFlowDirection.Down);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
var boxes = tree.Nodes.OfType().ToList();
Assert.Equal(3, boxes.Count);
@@ -178,11 +178,11 @@ public void Apply_DownDirection_DiffersFromRight()
{
var algorithm = new LayeredLayoutAlgorithm();
- var right = algorithm.Apply(BuildChain(), new LayoutOptions());
+ var right = algorithm.ApplyCore(BuildChain(), new LayoutOptions());
var downOptions = new LayoutOptions();
downOptions.Set(CoreOptions.Direction, LayoutFlowDirection.Down);
- var down = algorithm.Apply(BuildChain(), downOptions);
+ var down = algorithm.ApplyCore(BuildChain(), downOptions);
// Rightward is wide-and-short; downward is tall-and-narrow. They must not be identical.
Assert.True(right.Width > right.Height);
@@ -204,7 +204,7 @@ public void Apply_DownDirectionOnGraphScope_IsHonored()
var graph = BuildChain();
graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
Assert.True(tree.Height > tree.Width);
}
@@ -216,7 +216,7 @@ public void Apply_DownDirectionOnGraphScope_IsHonored()
[Fact]
public void Apply_DefaultDirection_FlowsLeftToRight()
{
- var tree = new LayeredLayoutAlgorithm().Apply(BuildChain(), new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(BuildChain(), new LayoutOptions());
var boxes = tree.Nodes.OfType().ToList();
Assert.True(boxes[0].X < boxes[1].X);
@@ -254,7 +254,7 @@ public void Apply_FlatGraphWithNoChildren_PlacesTopLevelStructureUnchanged()
Assert.All(new[] { a, b, c }, node => Assert.False(node.HasChildren));
// Act: lay the flat graph out with the bundled algorithm
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: the placed structure matches the classic flat result (one box per node,
// one connector per edge, left-to-right chain ordering)
@@ -294,7 +294,7 @@ public void Apply_SmallNodeChain_PlacesWithoutThrowingInEveryDirection(LayoutFlo
options.Set(CoreOptions.Direction, direction);
// Act: laying out must not throw regardless of direction.
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
// Assert: a valid placed tree with one box per node and one connector per edge.
var boxes = tree.Nodes.OfType().ToList();
@@ -328,7 +328,7 @@ public void Apply_DownDirection_FolderTarget_ProjectsEndpointToRecessedTop()
var options = new LayoutOptions();
options.Set(CoreOptions.Direction, LayoutFlowDirection.Down);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
var targetBox = tree.Nodes.OfType().Single(b => b.Label == "Utilities");
var line = Assert.Single(tree.Nodes.OfType());
@@ -357,7 +357,7 @@ public void Apply_UpDirection_FolderSource_ProjectsEndpointToRecessedTop()
var options = new LayoutOptions();
options.Set(CoreOptions.Direction, LayoutFlowDirection.Up);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
var sourceBox = tree.Nodes.OfType().Single(b => b.Label == "Utilities");
var line = Assert.Single(tree.Nodes.OfType());
@@ -385,7 +385,7 @@ public void Apply_RightDirection_FolderTarget_LeftFaceUnaffectedByTab()
target.FolderTabHeight = 24.0;
graph.AddEdge("uses", source, target);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var targetBox = tree.Nodes.OfType().Single(b => b.Label == "Utilities");
var line = Assert.Single(tree.Nodes.OfType());
@@ -415,7 +415,7 @@ public void Apply_LeftDirection_FolderTarget_RightFaceUnaffectedByTab()
var options = new LayoutOptions();
options.Set(CoreOptions.Direction, LayoutFlowDirection.Left);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
var targetBox = tree.Nodes.OfType().Single(b => b.Label == "Utilities");
var line = Assert.Single(tree.Nodes.OfType());
@@ -443,7 +443,7 @@ public void Apply_DownDirection_NoteTarget_ExcludesFoldFromLandingZone()
var options = new LayoutOptions();
options.Set(CoreOptions.Direction, LayoutFlowDirection.Down);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
var targetBox = tree.Nodes.OfType().Single(b => b.Label == "Design Note");
var line = Assert.Single(tree.Nodes.OfType());
@@ -467,7 +467,7 @@ public void Apply_DownDirection_NoteTarget_ExcludesFoldFromLandingZone()
[Fact]
public void Apply_RectangleChain_MatchesPreShapeAwarenessOutput()
{
- var tree = new LayeredLayoutAlgorithm().Apply(BuildChain(), new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(BuildChain(), new LayoutOptions());
var boxes = tree.Nodes.OfType().ToList();
var lines = tree.Nodes.OfType().ToList();
@@ -499,7 +499,7 @@ public void Apply_RectangleChain_MatchesPreShapeAwarenessOutput()
[Fact]
public void Apply_DefaultNodeSpacing_MatchesPriorEngineBehavior()
{
- var tree = new LayeredLayoutAlgorithm().Apply(BuildFanOut(), new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(BuildFanOut(), new LayoutOptions());
Assert.Equal(-5.0, SiblingGap(tree), 6);
}
@@ -514,11 +514,11 @@ public void Apply_LargerNodeSpacing_WidensGapBetweenSiblings()
{
var smallOptions = new LayoutOptions();
smallOptions.Set(CoreOptions.NodeSpacing, 40.0);
- var smallGap = SiblingGap(new LayeredLayoutAlgorithm().Apply(BuildFanOut(), smallOptions));
+ var smallGap = SiblingGap(new LayeredLayoutAlgorithm().ApplyCore(BuildFanOut(), smallOptions));
var largeOptions = new LayoutOptions();
largeOptions.Set(CoreOptions.NodeSpacing, 100.0);
- var largeGap = SiblingGap(new LayeredLayoutAlgorithm().Apply(BuildFanOut(), largeOptions));
+ var largeGap = SiblingGap(new LayeredLayoutAlgorithm().ApplyCore(BuildFanOut(), largeOptions));
Assert.True(largeGap > smallGap);
}
@@ -535,13 +535,13 @@ public void Apply_NodeSpacingOnGraphScope_TakesPrecedenceOverOptions()
graphWithOverride.Set(CoreOptions.NodeSpacing, 100.0);
var options = new LayoutOptions();
options.Set(CoreOptions.NodeSpacing, 40.0);
- var overriddenGap = SiblingGap(new LayeredLayoutAlgorithm().Apply(graphWithOverride, options));
+ var overriddenGap = SiblingGap(new LayeredLayoutAlgorithm().ApplyCore(graphWithOverride, options));
var graphOnly = BuildFanOut();
graphOnly.Set(CoreOptions.NodeSpacing, 100.0);
- var graphOnlyGap = SiblingGap(new LayeredLayoutAlgorithm().Apply(graphOnly, new LayoutOptions()));
+ var graphOnlyGap = SiblingGap(new LayeredLayoutAlgorithm().ApplyCore(graphOnly, new LayoutOptions()));
- var optionsOnlyGap = SiblingGap(new LayeredLayoutAlgorithm().Apply(BuildFanOut(), options));
+ var optionsOnlyGap = SiblingGap(new LayeredLayoutAlgorithm().ApplyCore(BuildFanOut(), options));
// The graph's 100.0 wins over the options' 40.0, matching a graph-only resolution...
Assert.Equal(graphOnlyGap, overriddenGap, 6);
@@ -589,7 +589,7 @@ public void Apply_ParallelEdges_MergeParallelEdgesDefaultTrue_EmitsExactlyOneLin
graph.AddEdge("e3", a, b);
// Act
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: only one connector survives.
var lines = tree.Nodes.OfType().ToList();
@@ -616,7 +616,7 @@ public void Apply_ParallelEdges_MergeParallelEdgesFalse_RetainsEveryEdge()
options.Set(CoreOptions.MergeParallelEdges, false);
// Act
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
// Assert: all three connectors survive, each keeping its own label.
var lines = tree.Nodes.OfType().ToList();
@@ -642,7 +642,7 @@ public void Apply_ParallelEdges_MergeParallelEdgesDefaultTrue_OmitsLabelOnCollap
graph.AddEdge("e3", a, b).Label = "audit";
// Act
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: exactly one line survives, and its label is omitted (not "primary", the first
// edge's label).
@@ -662,7 +662,7 @@ public void Apply_SingleEdge_MergeParallelEdgesDefaultTrue_KeepsOwnLabel()
var b = graph.AddNode("b", 80, 40);
graph.AddEdge("e1", a, b).Label = "only";
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var line = Assert.Single(tree.Nodes.OfType());
Assert.Equal("only", line.MidpointLabel);
@@ -685,7 +685,7 @@ public void Apply_MergeParallelEdges_GraphOverridesOptions()
var options = new LayoutOptions();
options.Set(CoreOptions.MergeParallelEdges, true);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
Assert.Equal(2, tree.Nodes.OfType().Count());
}
@@ -707,7 +707,7 @@ public void Apply_EdgeWithPortEndpoint_EmitsLayoutPortWithExternalLabel()
graph.AddEdge("e1", port, target);
// Act
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: exactly one port is emitted, carrying the external label.
var ports = tree.Nodes.OfType().ToList();
@@ -737,7 +737,7 @@ public void Apply_NodeWithLeftPort_ComputesNonZeroContentInsetLeft()
graph.AddEdge("e1", source, port);
// Act
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: the target box (which receives the port) has a positive left inset, while its
// other three sides (and the port-free source box) stay zero.
@@ -770,7 +770,7 @@ public void Apply_EdgeWithPortEndpoint_ComputesFiniteMaxLabelWidth()
port.ExternalLabel = "output";
graph.AddEdge("e1", port, target);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var port1 = Assert.Single(tree.Nodes.OfType());
Assert.False(double.IsPositiveInfinity(port1.MaxLabelWidth));
@@ -800,7 +800,7 @@ public void Apply_NodeWithLongLeftPortLabel_GrowsWidthEnoughToAvoidSqueeze()
graph.AddEdge("e1", source, port);
// Act
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
// Assert: the target box grew past its caller-supplied 80px width to accommodate the floor.
var targetBox = tree.Nodes.OfType().Single(b => b.Label == null && b.ContentInsetLeft > 0);
@@ -838,7 +838,7 @@ public void Apply_NodeWithLongLeftAndRightPortLabels_BothSidesAvoidSqueeze()
graph.AddEdge("e1", source, inPort);
graph.AddEdge("e2", outPort, target);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var hubBox = tree.Nodes.OfType().Single(b => b.ContentInsetLeft > 0 && b.ContentInsetRight > 0);
Assert.True(hubBox.Width > 80.0, $"Expected hub width to grow past 80, was {hubBox.Width}.");
@@ -891,7 +891,7 @@ public void Apply_TitledNodeWithTwoLeftPortLabels_BottomPortRetainsDownwardShift
graph.AddEdge("e2", sourceB, bottomLeftPort);
graph.AddEdge("e3", hub, target);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var hubBox = tree.Nodes.OfType().Single(b => b.Label == "Hub");
var ports = tree.Nodes.OfType()
@@ -940,7 +940,7 @@ public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooSmall_AutoGrowsHeight
graph.AddEdge("e3", board, network);
graph.AddEdge("e4", board, graphics);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var boardBox = tree.Nodes.OfType().Single(b => b.Label == null);
var expectedFloor = 2.0 * LayeredLayoutMetrics.ConnectorClearance * 4;
@@ -996,7 +996,7 @@ public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooNarrow_AutoGrowsWidth
graph.AddEdge("e3", board, network);
graph.AddEdge("e4", board, graphics);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var boardBox = tree.Nodes.OfType().Single(b => b.Label == null);
var expectedFloor = 2.0 * LayeredLayoutMetrics.ConnectorClearance * 4;
@@ -1050,7 +1050,7 @@ public void Apply_TitledNodeWithSeveralUnlabeledPorts_PortsAvoidTitleBandAndAuto
graph.AddEdge($"e{k}", port, target);
}
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var boardBox = tree.Nodes.OfType().Single(b => b.Label == "board : Motherboard");
var expectedFloor = 2.0 * LayeredLayoutMetrics.ConnectorClearance * portCount;
@@ -1087,7 +1087,7 @@ public void Apply_NodeAlreadyLargeEnough_SizeUnchanged()
var node = graph.AddNode("roomy", 400, 300);
node.Label = "Roomy";
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var box = Assert.Single(tree.Nodes.OfType());
Assert.Equal(400.0, box.Width, 3);
@@ -1121,7 +1121,7 @@ public void Apply_NodeWithTopAndBottomPorts_TooSmall_AutoGrowsHeight()
options.Set(CoreOptions.Direction, LayoutFlowDirection.Down);
// Act
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
// Assert: the hub box grew taller than its caller-supplied 50px height.
var hubBox = tree.Nodes.OfType().Single(b => b.Label == "Hub");
@@ -1165,7 +1165,7 @@ public void Apply_AutoGrownNode_DoesNotOverlapSiblings()
var options = new LayoutOptions();
options.Set(CoreOptions.Direction, LayoutFlowDirection.Down);
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
var boxes = tree.Nodes.OfType().ToList();
for (var i = 0; i < boxes.Count; i++)
@@ -1187,8 +1187,8 @@ public void Apply_NoNodeNeedsGrowth_PassTwoSkipped_LayoutUnaffected()
{
var graph = BuildFanOut();
- var tree1 = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
- var tree2 = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree1 = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
+ var tree2 = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var boxes1 = tree1.Nodes.OfType().ToList();
var boxes2 = tree2.Nodes.OfType().ToList();
@@ -1229,7 +1229,7 @@ public void Apply_ThreeParallelLabeledEdges_LabelsLandOnTheirOwnLine()
graph.AddEdge("audit", producer, consumer).Label = "audit";
// Act
- var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var lines = tree.Nodes.OfType().Where(l => l.MidpointLabel != null).ToList();
Assert.Equal(3, lines.Count);
@@ -1288,7 +1288,7 @@ public void Apply_ThreeParallelLabeledEdges_Down_BoxWidthGrowsAndLabelsLandOnThe
options.Set(CoreOptions.Direction, LayoutFlowDirection.Down);
// Act
- var tree = new LayeredLayoutAlgorithm().Apply(graph, options);
+ var tree = new LayeredLayoutAlgorithm().ApplyCore(graph, options);
var boxes = tree.Nodes.OfType().ToList();
Assert.Equal(2, boxes.Count);
diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs
index 6d57bed..c3f9c75 100644
--- a/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs
+++ b/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs
@@ -97,12 +97,12 @@ public void CreateDefaultRegistry_ReturnsIndependentInstances()
Assert.False(second.Contains("stub"));
}
- /// A minimal stub used to prove registry independence.
- private sealed class StubAlgorithm : ILayoutAlgorithm
+ /// A minimal stub used to prove registry independence.
+ private sealed class StubAlgorithm : LayoutAlgorithmBase
{
- public string Id => "stub";
+ public override string Id => "stub";
- public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) =>
+ protected internal override LayoutTree ApplyCore(LayoutGraph graph, LayoutOptions options) =>
new(1, 1, []);
}
}
diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs
index 76e298e..0a14d4a 100644
--- a/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs
+++ b/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs
@@ -37,7 +37,7 @@ public void Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly()
var graph = BuildFlatGraph();
// Act
- var expected = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var expected = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var actual = LayoutEngine.Layout(graph);
// Assert
@@ -56,7 +56,7 @@ public void Layout_GraphDeclaresLayered_MatchesLayeredAlgorithmExactly()
graph.Set(CoreOptions.Algorithm, "layered");
// Act
- var expected = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var expected = new LayeredLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var actual = LayoutEngine.Layout(graph);
// Assert
@@ -75,7 +75,7 @@ public void Layout_GraphDeclaresContainment_MatchesContainmentAlgorithmExactly()
graph.Set(CoreOptions.Algorithm, "containment");
// Act
- var expected = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions());
+ var expected = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions());
var actual = LayoutEngine.Layout(graph);
// Assert
@@ -233,18 +233,18 @@ private static void AssertExact(double expected, double actual) =>
$"expected {expected:R} but got {actual:R}");
///
- /// A minimal stub returning a recognizable sentinel canvas so a
+ /// A minimal stub returning a recognizable sentinel canvas so a
/// test can prove it — and not a bundled algorithm — was the one applied.
///
- private sealed class StubAlgorithm : ILayoutAlgorithm
+ private sealed class StubAlgorithm : LayoutAlgorithmBase
{
public const double SentinelWidth = 4242.0;
public const double SentinelHeight = 2424.0;
- public string Id => "stub";
+ public override string Id => "stub";
- public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) =>
+ protected internal override LayoutTree ApplyCore(LayoutGraph graph, LayoutOptions options) =>
new(SentinelWidth, SentinelHeight, []);
}
}
diff --git a/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs b/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs
index c9ed0dc..1a71781 100644
--- a/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs
+++ b/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs
@@ -268,6 +268,53 @@ public void PngRenderer_Render_NoteBoxWithCompartmentAndNoTitle_NoStrayLinePastF
Assert.Equal(-1, rightmost);
}
+ ///
+ /// Proves that when a Note-shaped box has an empty leading compartment followed by a
+ /// populated compartment, the second compartment's divider (a full-width horizontal line
+ /// unconditionally drawn since it isn't the specially-guarded first compartment) is never
+ /// drawn inside the folded-corner cutout: no divider-colored pixel appears at the box's
+ /// interior mid-width column above the fold's bottom edge.
+ ///
+ [Fact]
+ public void PngRenderer_Render_NoteBoxWithEmptyLeadingCompartment_NoDividerAboveFoldBottom()
+ {
+ // Arrange: a Note-shaped box with an empty leading compartment then a populated one
+ var renderer = new PngRenderer();
+ var emptyCompartment = new LayoutCompartment(null, []);
+ var contentCompartment = new LayoutCompartment(null, ["Some body text"]);
+ var box = new LayoutBox(10, 10, 150, 80, null, 0, BoxShape.Note, [emptyCompartment, contentCompartment], []);
+ var options = new RenderOptions(Themes.Light);
+ var background = SKColor.Parse(Themes.Light.BackgroundColor);
+
+ using var stream = new MemoryStream();
+ renderer.Render(new LayoutTree(200, 120, [box]), options, stream);
+ stream.Position = 0;
+ using var data = SKData.Create(stream);
+ using var bitmap = SKBitmap.Decode(data);
+ Assert.NotNull(bitmap);
+
+ // Scan an interior mid-width column (away from the border strokes and the fold's diagonal
+ // edge) from the box's top edge down to the fold's bottom edge, where a full-width divider
+ // would otherwise cross. Any divider found here must be at or below the fold's bottom.
+ var fold = BoxMetrics.NoteFoldSize(box);
+ var scale = options.Scale;
+ var midX = (int)((box.X + box.Width / 2.0) * scale);
+ var yTop = (int)(box.Y * scale);
+ var yFoldBottom = (int)((box.Y + fold) * scale);
+
+ var strayDivider = false;
+ for (var y = yTop + 1; y < yFoldBottom - 1; y++)
+ {
+ if (bitmap!.GetPixel(midX, y) != background)
+ {
+ strayDivider = true;
+ break;
+ }
+ }
+
+ Assert.False(strayDivider);
+ }
+
///
/// Proves that when 3+ parallel labeled connectors force the label placer to nudge labels
/// downward to avoid collisions, the raster renderer grows the bitmap (rather than sizing it
diff --git a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs
index 7d153a9..49635dd 100644
--- a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs
+++ b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs
@@ -328,6 +328,49 @@ public void SvgRenderer_Render_NoteBoxWithCompartmentAndNoTitle_OmitsLeadingDivi
Assert.Contains("Some body text", svgText, StringComparison.Ordinal);
}
+ ///
+ /// Render a LayoutBox with a Note shape, no Label/Keyword, and an empty leading compartment
+ /// followed by a compartment with content does not draw the second compartment's divider
+ /// inside the folded-corner cutout. The empty leading compartment isn't the special-cased
+ /// "first compartment" that the title-area guard exempts, so without a fold-aware clamp the
+ /// divider above the second compartment would land at (or above) the box's top edge and
+ /// protrude past the fold as a stray line, the same defect the leading-divider fix addressed.
+ ///
+ [Fact]
+ public void SvgRenderer_Render_NoteBoxWithEmptyLeadingCompartment_ClampsSecondDividerBelowFold()
+ {
+ // Arrange: a Note-shaped box with an empty leading compartment then a populated one
+ var renderer = new SvgRenderer();
+ var emptyCompartment = new LayoutCompartment(null, []);
+ var contentCompartment = new LayoutCompartment(null, ["Some body text"]);
+ var box = new LayoutBox(10, 10, 150, 80, null, 0, BoxShape.Note, [emptyCompartment, contentCompartment], []);
+ var layout = new LayoutTree(200, 120, [box]);
+ var options = new RenderOptions(Themes.Light);
+ using var output = new MemoryStream();
+
+ // Act
+ renderer.Render(layout, options, output);
+
+ // Assert: every full-width divider line drawn for this box is at or below the fold's
+ // bottom edge, never inside the folded-corner cutout.
+ var foldBottomY = (box.Y + BoxMetrics.NoteFoldSize(box)) * options.Scale;
+
+ output.Position = 0;
+ var svgText = ReadAllText(output);
+ var document = System.Xml.Linq.XDocument.Parse(svgText);
+ var dividerYs = document.Descendants()
+ .Where(e => e.Name.LocalName == "line")
+ .Where(e =>
+ e.Attribute("x1") is { } x1 && IsClose(x1.Value, box.X * options.Scale) &&
+ e.Attribute("x2") is { } x2 && IsClose(x2.Value, (box.X + box.Width) * options.Scale))
+ .Select(e => double.Parse(e.Attribute("y1")!.Value, System.Globalization.CultureInfo.InvariantCulture))
+ .ToList();
+
+ Assert.NotEmpty(dividerYs);
+ Assert.All(dividerYs, y => Assert.True(y >= foldBottomY - 0.01, $"Divider at y={y} is above the fold bottom {foldBottomY}"));
+ Assert.Contains("Some body text", svgText, StringComparison.Ordinal);
+ }
+
/// Parses an SVG numeric attribute value and compares it to an expected value within a small tolerance.
private static bool IsClose(string attributeValue, double expected) =>
Math.Abs(double.Parse(attributeValue, System.Globalization.CultureInfo.InvariantCulture) - expected) < 0.01;