Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,6 @@ By contributing to this project, you agree that your contributions will be licen
[link-security]: https://sonarcloud.io/dashboard?id=demaconsulting_Rendering
[link-nuget]: https://www.nuget.org/packages/DemaConsulting.Rendering
[link-elk]: https://eclipse.dev/elk/
[link-gallery]: https://github.com/demaconsulting/Rendering/blob/main/docs/gallery/gallery.md
[link-gallery]: https://github.com/demaconsulting/Rendering/blob/main/docs/gallery/README.md
[link-user-guide]: https://github.com/demaconsulting/Rendering/blob/main/docs/user_guide/introduction.md
[link-contributing]: https://github.com/demaconsulting/Rendering/blob/main/CONTRIBUTING.md
35 changes: 20 additions & 15 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,27 @@ attach to entries here until they are actually implemented).
## Unimplemented `CoreOptions` behavior

All six `CoreOptions` properties now cascade correctly through the layout hierarchy (nearest-ancestor
inheritance via `PropertyHolder.OverlayOnto`, landed alongside this file). The following properties are
accepted and cascade correctly, but are not yet *read* by any bundled algorithm's layout logic:
inheritance via `PropertyHolder.OverlayOnto`, landed alongside this file). `CoreOptions.NodeSpacing` is
now honored by the bundled `layered` algorithm (see below). The following properties are still accepted
and cascade correctly, but are not yet *read* by any bundled algorithm's layout logic. Effort below is
ranked from easiest to hardest — it is not uniform across the two, despite both sharing the same
already-working cascading mechanism:

- **`CoreOptions.HierarchyHandling`** — only `HierarchyHandling.SeparateChildren` exists and is
implicitly what the bundled `HierarchicalLayoutAlgorithm` does; no algorithm branches on this
property's value. Additional modes (e.g. an ELK-style "include children in the parent's own
layout space" mode) are not implemented.
- **`CoreOptions.NodeSpacing`** — advisory; the bundled `layered` algorithm uses fixed engine
metrics instead of this value.
- **`CoreOptions.LayerSpacing`** — advisory; same as `NodeSpacing`, fixed engine metrics are used
instead.

Because these properties are ordinary `LayoutProperty<T>` values stored via `PropertyHolder`, the
generalized cascading mechanism already applies to them automatically — implementing any of the
above only requires an algorithm to start reading the (already-cascaded) effective value; no new
option-resolution plumbing is required.
- **`CoreOptions.LayerSpacing`** (needs a design decision first) — advisory; the bundled `layered`
algorithm uses a fixed engine constant (`LayeredLayoutMetrics.CorridorMinWidth`, 70.0) instead. Unlike
`NodeSpacing`, there is no clean 1:1 replacement: `CorridorMinWidth` is *derived* from connector-routing
slot math (`2×ConnectorClearance + (edgeCount−1)×EdgeSpacing`), not a free-standing spacing value. A
user-supplied `LayerSpacing` smaller than what routing needs must not silently override the
routing-derived minimum, or connectors will overlap — implementation must decide whether the option
acts as an additional floor on top of the routing minimum, a validated hard override, or something
else, before any code is written.
- **`CoreOptions.HierarchyHandling`** (hardest) — only `HierarchyHandling.SeparateChildren` exists and
is implicitly what the bundled `HierarchicalLayoutAlgorithm` does; no algorithm branches on this
property's value, and no second mode exists yet. Implementing an additional mode (e.g. an ELK-style
"include children in the parent's own layout space" mode) is genuine new layout logic in
`HierarchicalLayoutAlgorithm` — not a matter of reading an already-cascaded value — and introduces new
user-visible layout behavior, so it belongs in a proper planning/implementation pass rather than an
opportunistic fix.

## Process note

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ with `ConnectorWaypoints`.

#### InterconnectionLayoutEngine Methods

`Place(nodes, edges, direction)` builds a `LayeredGraph` from the inputs, assembles a
`Place(nodes, edges, direction, nodeSpacing)` builds a `LayeredGraph` from the inputs, assembles a
`LayeredLayoutPipeline` with the default stages for the requested `direction` (defaulting to Right),
and runs it. It then reads the placed coordinates, column extents, layer assignments, and waypoints
from the graph state and assembles the `LayerResult`. Because the pipeline drops self-loops,
de-duplicates identical directed pairs, and reverses back edges, `ConnectorWaypoints` holds one
polyline per acyclic edge; consumers key a `(source, target)` lookup on `AcyclicEdges` (reversing the
polyline for a reversed back edge) to recover each input edge's route.
and runs it. The optional `nodeSpacing` argument (defaulting to the engine's original fixed constant)
is stored on the `LayeredGraph` and consumed by the Brandes-Köpf placement stage as the minimum gap
between same-layer nodes; omitting it reproduces the engine's original geometry exactly. It then reads
the placed coordinates, column extents, layer assignments, and waypoints from the graph state and
assembles the `LayerResult`. Because the pipeline drops self-loops, de-duplicates identical directed
pairs, and reverses back edges, `ConnectorWaypoints` holds one polyline per acyclic edge; consumers key
a `(source, target)` lookup on `AcyclicEdges` (reversing the polyline for a reversed back edge) to
recover each input edge's route.

The reported total dimensions are direction-aware. The pipeline's axis-transform stage places the
nodes along the requested direction, so a top-to-bottom (Down) or bottom-to-top (Up) flow transposes
Expand Down Expand Up @@ -65,4 +68,5 @@ strategy to obtain a placement result.
| Rendering-Layout-InterconnectionEngine-Direction-RequestedFlow | See above |
| Rendering-Layout-InterconnectionEngine-Direction-TransposedTotals | See above |
| Rendering-Layout-InterconnectionEngine-Direction-DefaultsToRight | See above |
| Rendering-Layout-InterconnectionEngine-NodeSpacing | See above |
| Rendering-Layout-InterconnectionEngine-Deterministic | InterconnectionLayoutEngine behavior described above |
5 changes: 4 additions & 1 deletion docs/design/rendering-layout/engine/layered-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ dummies), the per-layer node groups, the augmented-node coordinate arrays, the c
per-sub-edge port positions, the per-sub-edge bend points, and finally the assembled per-edge
waypoints. The `BackEdgeEntryApproach` parameter (default `ConnectorClearance`) lets a
decoration-aware caller lengthen a reversed edge's final approach without disturbing default
geometry. The `SwapNodeAxes` seam swaps each node's width and height for the down/up directions so
geometry. The `NodeSpacing` parameter (default `LayeredLayoutMetrics.NodeSpacing`) is the minimum gap
`BrandesKopfPlacer` enforces between same-layer nodes during horizontal compaction; a caller-supplied
value replaces the pipeline's original fixed constant without disturbing default geometry when left
at its default. The `SwapNodeAxes` seam swaps each node's width and height for the down/up directions so
the direction-agnostic stages space layers by the correct extent; it preserves every other
`LayerNode` field (`Shape`, `RoundedCornerRadius`, `FolderTabWidth`, `FolderTabHeight`, `Label`,
`RealWidth`, `RealHeight`) unchanged, since only the abstract along/cross axes need to reorient.
Expand Down
18 changes: 12 additions & 6 deletions docs/design/rendering-layout/layered-layout-algorithm.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,18 @@ per node followed by `LayoutLine` per edge).
taking an explicit value on the graph in preference to one on the options and falling back to the
property default (`Right`), then maps the public `LayoutFlowDirection` to the engine's internal
direction. This mirrors how the algorithm resolves its other well-known options.
4. **Placement.** Calls `InterconnectionLayoutEngine.Place` with the resolved direction to obtain the
`LayerResult`. For a downward or upward flow the engine transposes the layout so the layers
progress top-to-bottom (or bottom-to-top); `Right` is the default and is byte-identical to the
original left-to-right placement.
5. **Box emission.** Emits one `LayoutBox` per input node, in input order, at the placed rectangle,
4. **Node-spacing resolution.** Resolves the requested minimum same-layer node gap from
`CoreOptions.NodeSpacing` using the same graph-then-options-then-default precedence as direction
resolution, and passes the resolved value to `InterconnectionLayoutEngine.Place`. The property's
default matches the engine's original fixed constant, so an unset option reproduces the algorithm's
prior behavior exactly.
5. **Placement.** Calls `InterconnectionLayoutEngine.Place` with the resolved direction and node
spacing to obtain the `LayerResult`. For a downward or upward flow the engine transposes the layout
so the layers progress top-to-bottom (or bottom-to-top); `Right` is the default and is
byte-identical to the original left-to-right placement.
6. **Box emission.** Emits one `LayoutBox` per input node, in input order, at the placed rectangle,
carrying the node label.
6. **Route resolution.** Builds a `(source, target)` to polyline lookup from the engine's acyclic
7. **Route resolution.** Builds a `(source, target)` to polyline lookup from the engine's acyclic
edge set, then emits one `LayoutLine` per input edge. `ResolveRoute` returns the forward polyline
when present, reverses the polyline of a reversed back edge, and otherwise falls back to a
straight segment between the two node centers (for a self-loop or duplicate edge the engine
Expand All @@ -65,5 +70,6 @@ the Engine subsystem. It is the entry point resolved by renderers through the la
| Rendering-Layout-LayeredAlgorithm-PlacesAndRoutes | LayeredLayoutAlgorithm behavior described above |
| Rendering-Layout-LayeredAlgorithm-EmptyGraph | LayeredLayoutAlgorithm behavior described above |
| Rendering-Layout-LayeredAlgorithm-Direction | LayeredLayoutAlgorithm behavior described above |
| Rendering-Layout-LayeredAlgorithm-NodeSpacing | LayeredLayoutAlgorithm behavior described above |
| Rendering-Layout-LayeredAlgorithm-Validation | LayeredLayoutAlgorithm behavior described above |
| Rendering-Layout-LayeredAlgorithm-ShapeAwareRouting | LayeredLayoutAlgorithm behavior described above |
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@ sections:
tests:
- Place_DefaultDirection_MatchesRightFlow

- id: Rendering-Layout-InterconnectionEngine-NodeSpacing
title: >-
InterconnectionLayoutEngine shall accept an optional node-spacing value controlling the
minimum gap between same-layer nodes, defaulting to the engine's original fixed constant so
existing callers see identical geometry when the argument is omitted.
justification: |
The public LayeredLayoutAlgorithm resolves a caller-requested node spacing and must thread it
through this facade to the pipeline stage that enforces the minimum gap; the default must
preserve prior behavior for every caller that predates this parameter.
tests:
- Place_DefaultNodeSpacing_MatchesExplicitEngineConstant
- Place_LargerNodeSpacing_WidensSiblingGap

- id: Rendering-Layout-InterconnectionEngine-Deterministic
title: >-
Given identical input, InterconnectionLayoutEngine shall produce identical geometry and
Expand Down
16 changes: 16 additions & 0 deletions docs/reqstream/rendering-layout/layered-layout-algorithm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ sections:
- Apply_DownDirectionOnGraphScope_IsHonored
- Apply_DefaultDirection_FlowsLeftToRight

- id: Rendering-Layout-LayeredAlgorithm-NodeSpacing
title: >-
LayeredLayoutAlgorithm shall use the node-spacing option to control the minimum gap between
same-layer nodes, reading the selection from the graph in preference to the options and
falling back to the property default, which matches the engine's original fixed constant.
justification: |
CoreOptions.NodeSpacing previously cascaded correctly but was never read by any bundled
algorithm, so a caller's requested spacing had no observable effect on the placed layout.
Honoring it lets callers widen or narrow the gap between sibling nodes without altering the
default rendering of any existing diagram, since the property's default now matches the
engine's pre-existing fixed constant.
tests:
- Apply_DefaultNodeSpacing_MatchesPriorEngineBehavior
- Apply_LargerNodeSpacing_WidensGapBetweenSiblings
- Apply_NodeSpacingOnGraphScope_TakesPrecedenceOverOptions

- id: Rendering-Layout-LayeredAlgorithm-Validation
title: >-
LayeredLayoutAlgorithm shall reject a null graph or null options argument with an
Expand Down
6 changes: 3 additions & 3 deletions docs/user_guide/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ engine.Compartments = [new LayoutCompartment("ports", ["intake", "exhaust"])];
Only the node's `Width`/`Height` affect layout placement — a caller is responsible for sizing a node
large enough to fit its keyword line and compartment rows (see `BoxMetrics.TitleAreaHeight` and the
per-compartment row heights in `DemaConsulting.Rendering.Abstractions`); no leaf algorithm grows a box
to fit its own appearance. The [gallery's "Box appearance" diagram](../gallery/gallery.md) shows a
to fit its own appearance. The [gallery's "Box appearance" diagram](../gallery/README.md) shows a
complete, rendered example.

When a container node (one with `Children`) also carries a `Label`, `HierarchicalLayoutAlgorithm`
Expand Down Expand Up @@ -434,14 +434,14 @@ one-call convenience while resolving against your own set of algorithms.

# Gallery

A browsable gallery (the `gallery.md` document under `docs/gallery/`) showcases what the library can
A browsable gallery (the `README.md` document under `docs/gallery/`) showcases what the library can
produce: a layered diagram, a containment-packed diagram, a hierarchical nested diagram with a
cross-container edge, orthogonal edge routing around an obstacle, the three built-in themes on one
diagram, and both the SVG and PNG output paths. Every image is generated by the
`DemaConsulting.Rendering.Gallery` test project directly from the public API, so the gallery doubles
as an end-to-end rendering smoke test that runs on every build.

To regenerate the committed gallery under `docs/gallery/` — the images and the `gallery.md` index —
To regenerate the committed gallery under `docs/gallery/` — the images and the `README.md` index —
run the following from the repository root:

```pwsh
Expand Down
2 changes: 1 addition & 1 deletion gallery.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# and simply assert. This script points the RENDERING_GALLERY_DIR environment
# variable at the committed docs/gallery/ folder and runs ONLY the gallery
# project, so the same facts (re)write the committed SVG/PNG images and the
# browsable docs/gallery/gallery.md index.
# browsable docs/gallery/README.md index.
#
# USAGE:
# pwsh ./gallery.ps1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,17 @@ internal static class InterconnectionLayoutEngine
/// <see cref="LayoutDirection.Right"/>, which is byte-identical to the original engine; the other
/// directions are realized by the pipeline's <see cref="AxisTransform"/> stage.
/// </param>
/// <param name="nodeSpacing">
/// The minimum vertical gap enforced between adjacent nodes stacked in the same layer. Defaults to
/// <see cref="Layered.LayeredLayoutMetrics.NodeSpacing"/>, which is byte-identical to the original
/// engine's fixed constant.
/// </param>
/// <returns>Placement result with rects, layer assignments, and connector waypoints.</returns>
public static LayerResult Place(
IReadOnlyList<LayerNode> nodes,
IReadOnlyList<LayerEdge> edges,
LayoutDirection direction = LayoutDirection.Right)
LayoutDirection direction = LayoutDirection.Right,
double nodeSpacing = Layered.LayeredLayoutMetrics.NodeSpacing)
{
ArgumentNullException.ThrowIfNull(nodes);
ArgumentNullException.ThrowIfNull(edges);
Expand All @@ -141,7 +147,7 @@ public static LayerResult Place(
return new LayerResult([], 2.0 * Padding, 2.0 * Padding, [], []);
}

var graph = new LayeredGraph(nodes, edges, direction);
var graph = new LayeredGraph(nodes, edges, direction) { NodeSpacing = nodeSpacing };
var pipeline = LayeredLayoutPipeline.Builder()
.Direction(direction)
.Hierarchy(Layered.HierarchyHandling.Flat)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
public void Apply(LayeredGraph graph)
{
ArgumentNullException.ThrowIfNull(graph);
var (augX, augY, columnX, maxColWidth) = AssignCoordinatesAug(graph.AugNodes, graph.Groups, graph.AugEdges);
var (augX, augY, columnX, maxColWidth) = AssignCoordinatesAug(
graph.AugNodes, graph.Groups, graph.AugEdges, graph.NodeSpacing);
graph.AugX = augX;
graph.AugY = augY;
graph.ColumnX = columnX;
Expand All @@ -38,10 +39,11 @@
/// Corridor widths are derived from sub-edge counts per corridor.
/// </para>
/// </remarks>
private static (double[] AugX, double[] AugY, double[] ColumnX, double[] MaxColWidth) AssignCoordinatesAug(

Check warning on line 42 in src/DemaConsulting.Rendering.Layout/Engine/Layered/BrandesKopfPlacer.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
List<AugNode> augNodes,
List<List<int>> groups,
List<AugEdge> augEdges)
List<AugEdge> augEdges,
double nodeSpacing)
{
var layerCount = groups.Count;
var numAug = augNodes.Count;
Expand Down Expand Up @@ -91,7 +93,7 @@
}

// Assign Y coordinates using the Brandes-Köpf balanced four-layout algorithm.
var augY = BkAssignYCoordinates(augNodes, groups, augEdges);
var augY = BkAssignYCoordinates(augNodes, groups, augEdges, nodeSpacing);

return (augX, augY, columnX, maxColWidth);
}
Expand All @@ -115,7 +117,8 @@
private static double[] BkAssignYCoordinates(
List<AugNode> augNodes,
List<List<int>> groups,
List<AugEdge> augEdges)
List<AugEdge> augEdges,
double nodeSpacing)
{
var numAug = augNodes.Count;

Expand Down Expand Up @@ -153,7 +156,8 @@
rightNeighborEdges, leftNeighborEdges);

// Step 4: horizontal compaction — assigns absolute Y to each block root.
var blockY = BkHorizontalCompaction(augNodes, groups, root, align, innerShift, posInLayer, vDown);
var blockY = BkHorizontalCompaction(
augNodes, groups, root, align, innerShift, posInLayer, vDown, nodeSpacing);

// Compute absolute Y for every node in this layout.
var y = new double[numAug];
Expand Down Expand Up @@ -188,7 +192,7 @@
/// alignment. Both lists are stable-sorted by edge index as a tiebreaker.
/// </para>
/// </remarks>
private static void BkPreprocess(

Check warning on line 195 in src/DemaConsulting.Rendering.Layout/Engine/Layered/BrandesKopfPlacer.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Refactor this method to reduce its Cognitive Complexity from 49 to the 15 allowed.
List<AugNode> augNodes,
List<List<int>> groups,
List<AugEdge> augEdges,
Expand Down Expand Up @@ -659,7 +663,7 @@

/// <summary>
/// Assigns an absolute Y coordinate (blockY) to each block root by compacting blocks
/// in the vertical direction, respecting per-node heights and <see cref="NodeSpacing"/> gaps.
/// in the vertical direction, respecting per-node heights and <paramref name="nodeSpacing"/> gaps.
/// </summary>
/// <remarks>
/// <para>
Expand All @@ -681,7 +685,8 @@
int[] align,
double[] innerShift,
int[] posInLayer,
bool vDown)
bool vDown,
double nodeSpacing)
{
var maxLayer = groups.Count - 1;
var blockY = new double[augNodes.Count];
Expand Down Expand Up @@ -717,7 +722,7 @@
var requiredY = blockY[aboveRoot]
+ innerShift[above]
+ augNodes[above].Height
+ NodeSpacing
+ nodeSpacing
- innerShift[current];
blockY[v] = Math.Max(blockY[v], requiredY);
}
Expand All @@ -733,7 +738,7 @@

var requiredY = blockY[belowRoot]
+ innerShift[below]
- NodeSpacing
- nodeSpacing
- augNodes[current].Height
- innerShift[current];
blockY[v] = Math.Min(blockY[v], requiredY);
Expand Down
Loading
Loading