From 649fa3d0a60fa2eb055db61ad71f16ed32f01541 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 15 Jul 2026 16:42:30 -0400 Subject: [PATCH 1/3] Add auto-layout algorithm, restructure gallery, remediate formal-review findings Adds the "auto" meta-layout-algorithm that routes each connected component of a graph to the layered, containment, or hierarchical engine based on its shape, then packs the results together. Adds edge-count-aware gap widening shared across the containment and hierarchical algorithms via a common EdgeCountGapWidener helper. Restructures docs/gallery/ into topic-based groups, each with its own README, and rewrites the top-level docs/gallery/README.md as a marketing index that links to the group pages. Enforces that every gallery image is produced via LayoutEngine.Layout, isolating the one hand-built LayoutTree example into a dedicated custom-rendering group. Remediates all 16 findings from the formal-review pass across the 18 review-sets covering this branch's changes: - Fixes a real ComponentPacker/LayeredLayoutPipeline double axis-swap bug by making AxisTransform.NormalizeInputAxes idempotent per graph. - Strengthens a weak AutoLayoutAlgorithm test to actually assert that a graph-level NodeSpacing override reaches a split-off component. - Splits several compound reqstream requirements (hierarchical-layout- algorithm, containment-layout, containment-layout-algorithm) into atomic, independently-verifiable requirements, updating design/verification docs and coverage tables to match. - Fixes stale documentation: removed a nonexistent orthogonal-router test reference, corrected ConnectorRouter's Connection XML doc describing obstacle handling, and added missing dependencies to the connector-router design doc. - Fixes GalleryShowcaseTests.cs: adds AAA-style Arrange/Act-Assert comments to every fact, removes an orphaned XML doc summary, and adds the missing Gallery_ContainmentManySmallWideBoxes_RendersSvg fact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 4 + .reviewmark.yaml | 50 + README.md | 28 +- ROADMAP.md | 3 +- docs/design/introduction.md | 11 +- docs/design/ots/sonarscanner.md | 25 + docs/design/rendering-layout.md | 33 +- .../rendering-layout/auto-layout-algorithm.md | 113 +++ .../rendering-layout/connector-router.md | 10 + .../containment-layout-algorithm.md | 28 +- .../rendering-layout/containment-layout.md | 27 +- .../design/rendering-layout/default-layout.md | 13 +- .../engine/containment-packer.md | 44 +- .../engine/edge-count-gap-widener.md | 70 ++ .../engine/interconnection-layout-engine.md | 9 +- .../engine/layered-pipeline.md | 32 +- .../engine/layout-tree-packer.md | 87 ++ .../engine/orthogonal-edge-router.md | 7 +- .../hierarchical-layout-algorithm.md | 31 +- docs/gallery/README.md | 233 +---- docs/gallery/appearance-and-themes/README.md | 52 ++ .../box-appearance.svg | 0 .../folder-top-face-anchor.svg | 0 .../shape-gallery.svg | 0 .../theme-dark.png | Bin .../theme-light.png | Bin .../theme-print.png | Bin .../connectivity-and-clusters/README.md | 64 ++ .../auto-all-isolated.svg | 43 + .../auto-cluster-plus-isolated.svg | 44 + .../auto-multiple-disconnected-clusters.svg | 48 + .../containment-many-small-wide-boxes.svg | 57 ++ .../containment-packed.svg | 0 ...layered-multiple-disconnected-clusters.svg | 48 + .../layered-regression-baseline.svg | 44 + docs/gallery/custom-rendering/README.md | 33 + ...dges-into-compartment-box-side-by-side.svg | 57 ++ docs/gallery/flow-pipeline/README.md | 52 ++ .../{ => flow-pipeline}/direction-down.svg | 0 .../{ => flow-pipeline}/direction-right.svg | 0 .../{ => flow-pipeline}/layered-pipeline.png | Bin .../{ => flow-pipeline}/layered-pipeline.svg | 0 .../mixed-direction-nested.svg | 0 .../orthogonal-obstacle.svg | 0 docs/gallery/layout-regressions/README.md | 18 + .../isolated-node-layer-gap.svg | 26 +- docs/gallery/nested-hierarchy/README.md | 65 ++ .../auto-nested-routes-hierarchical.svg | 42 + .../boundary-ports-showcase-deep-chain.png | Bin .../boundary-ports-showcase-deep-chain.svg | 0 .../boundary-ports-showcase-horizontal.png | Bin .../boundary-ports-showcase-horizontal.svg | 0 .../boundary-ports-showcase-vertical.png | Bin .../boundary-ports-showcase-vertical.svg | 0 .../hierarchical-nested.png | Bin .../hierarchical-nested.svg | 0 .../parallel-edges-and-ports/README.md | 87 ++ ...ontainment-parallel-edges-side-by-side.svg | 67 ++ ...erarchical-parallel-edges-side-by-side.svg | 71 ++ .../parallel-edges-into-compartment-box.svg | 0 .../parallel-edges-merged.png | Bin .../parallel-edges-merged.svg | 0 .../parallel-edges-preserved-vertical.png | Bin .../parallel-edges-preserved-vertical.svg | 0 .../parallel-edges-preserved.png | Bin .../parallel-edges-preserved.svg | 0 .../ports-showcase-horizontal.png | Bin .../ports-showcase-horizontal.svg | 0 ...ts-showcase-multi-connector-horizontal.png | Bin ...ts-showcase-multi-connector-horizontal.svg | 0 ...orts-showcase-multi-connector-vertical.png | Bin ...orts-showcase-multi-connector-vertical.svg | 0 .../ports-showcase-unlabeled-fan-out.png | Bin .../ports-showcase-unlabeled-fan-out.svg | 0 .../ports-showcase-vertical.png | Bin .../ports-showcase-vertical.svg | 0 docs/reqstream/ots/sonarscanner.yaml | 21 + docs/reqstream/rendering-layout.yaml | 36 +- .../auto-layout-algorithm.yaml | 123 +++ .../containment-layout-algorithm.yaml | 41 + .../rendering-layout/containment-layout.yaml | 36 + .../rendering-layout/default-layout.yaml | 7 +- .../engine/containment-packer.yaml | 10 + .../engine/edge-count-gap-widener.yaml | 46 + .../engine/layered-pipeline.yaml | 32 + .../engine/layout-tree-packer.yaml | 98 ++ .../engine/orthogonal-edge-router.yaml | 44 +- .../hierarchical-layout-algorithm.yaml | 117 ++- docs/user_guide/introduction.md | 144 ++- docs/verification/introduction.md | 13 +- docs/verification/ots/sonarscanner.md | 44 + docs/verification/rendering-layout.md | 9 +- .../rendering-layout/auto-layout-algorithm.md | 105 +++ .../containment-layout-algorithm.md | 24 +- .../rendering-layout/containment-layout.md | 21 +- .../rendering-layout/default-layout.md | 12 +- .../engine/containment-packer.md | 5 + .../engine/edge-count-gap-widener.md | 54 ++ .../rendering-layout/engine/engine.md | 13 +- .../engine/layered-pipeline.md | 16 + .../engine/layout-tree-packer.md | 77 ++ .../engine/orthogonal-edge-router.md | 28 +- .../hierarchical-layout-algorithm.md | 42 +- gallery.ps1 | 6 +- requirements.yaml | 4 + .../AutoLayoutAlgorithm.cs | 460 +++++++++ .../ConnectorRouter.cs | 12 +- .../ContainmentLayout.cs | 17 +- .../ContainmentLayoutAlgorithm.cs | 96 +- .../Engine/ContainmentPacker.cs | 33 +- .../Engine/EdgeCountGapWidener.cs | 70 ++ .../Engine/InterconnectionLayoutEngine.cs | 86 +- .../Engine/Layered/AxisTransform.cs | 12 + .../Engine/Layered/ComponentPacker.cs | 56 +- .../Engine/Layered/LayeredGraph.cs | 15 + .../Engine/LayoutTreePacker.cs | 198 ++++ .../HierarchicalLayoutAlgorithm.cs | 244 ++++- .../LayoutAlgorithms.cs | 19 +- .../GalleryCatalog.cs | 882 ++++++++++++------ .../GalleryDiagrams.cs | 284 ++++++ .../GalleryIndex.cs | 112 ++- .../GalleryIndexTests.cs | 113 ++- .../GalleryShowcaseTests.cs | 238 +++++ .../GalleryWriter.cs | 26 + .../AutoLayoutAlgorithmTests.cs | 403 ++++++++ .../ConnectorRouterTests.cs | 155 +++ .../ContainmentLayoutAlgorithmTests.cs | 109 +++ .../ContainmentLayoutTests.cs | 41 + .../Engine/ContainmentPackerTests.cs | 91 ++ .../Engine/EdgeCountGapWidenerTests.cs | 108 +++ .../Engine/Layered/ComponentPackerTests.cs | 38 + .../LayeredPipelineEquivalenceTests.cs | 147 ++- .../Engine/LayoutTreePackerTests.cs | 278 ++++++ .../Engine/OrthogonalEdgeRouterTests.cs | 113 +++ .../HierarchicalLayoutAlgorithmTests.cs | 225 +++++ .../LayoutAlgorithmsTests.cs | 22 +- 136 files changed, 7015 insertions(+), 692 deletions(-) create mode 100644 docs/design/ots/sonarscanner.md create mode 100644 docs/design/rendering-layout/auto-layout-algorithm.md create mode 100644 docs/design/rendering-layout/engine/edge-count-gap-widener.md create mode 100644 docs/design/rendering-layout/engine/layout-tree-packer.md create mode 100644 docs/gallery/appearance-and-themes/README.md rename docs/gallery/{ => appearance-and-themes}/box-appearance.svg (100%) rename docs/gallery/{ => appearance-and-themes}/folder-top-face-anchor.svg (100%) rename docs/gallery/{ => appearance-and-themes}/shape-gallery.svg (100%) rename docs/gallery/{ => appearance-and-themes}/theme-dark.png (100%) rename docs/gallery/{ => appearance-and-themes}/theme-light.png (100%) rename docs/gallery/{ => appearance-and-themes}/theme-print.png (100%) create mode 100644 docs/gallery/connectivity-and-clusters/README.md create mode 100644 docs/gallery/connectivity-and-clusters/auto-all-isolated.svg create mode 100644 docs/gallery/connectivity-and-clusters/auto-cluster-plus-isolated.svg create mode 100644 docs/gallery/connectivity-and-clusters/auto-multiple-disconnected-clusters.svg create mode 100644 docs/gallery/connectivity-and-clusters/containment-many-small-wide-boxes.svg rename docs/gallery/{ => connectivity-and-clusters}/containment-packed.svg (100%) create mode 100644 docs/gallery/connectivity-and-clusters/layered-multiple-disconnected-clusters.svg create mode 100644 docs/gallery/connectivity-and-clusters/layered-regression-baseline.svg create mode 100644 docs/gallery/custom-rendering/README.md create mode 100644 docs/gallery/custom-rendering/parallel-edges-into-compartment-box-side-by-side.svg create mode 100644 docs/gallery/flow-pipeline/README.md rename docs/gallery/{ => flow-pipeline}/direction-down.svg (100%) rename docs/gallery/{ => flow-pipeline}/direction-right.svg (100%) rename docs/gallery/{ => flow-pipeline}/layered-pipeline.png (100%) rename docs/gallery/{ => flow-pipeline}/layered-pipeline.svg (100%) rename docs/gallery/{ => flow-pipeline}/mixed-direction-nested.svg (100%) rename docs/gallery/{ => flow-pipeline}/orthogonal-obstacle.svg (100%) create mode 100644 docs/gallery/layout-regressions/README.md rename docs/gallery/{ => layout-regressions}/isolated-node-layer-gap.svg (80%) create mode 100644 docs/gallery/nested-hierarchy/README.md create mode 100644 docs/gallery/nested-hierarchy/auto-nested-routes-hierarchical.svg rename docs/gallery/{ => nested-hierarchy}/boundary-ports-showcase-deep-chain.png (100%) rename docs/gallery/{ => nested-hierarchy}/boundary-ports-showcase-deep-chain.svg (100%) rename docs/gallery/{ => nested-hierarchy}/boundary-ports-showcase-horizontal.png (100%) rename docs/gallery/{ => nested-hierarchy}/boundary-ports-showcase-horizontal.svg (100%) rename docs/gallery/{ => nested-hierarchy}/boundary-ports-showcase-vertical.png (100%) rename docs/gallery/{ => nested-hierarchy}/boundary-ports-showcase-vertical.svg (100%) rename docs/gallery/{ => nested-hierarchy}/hierarchical-nested.png (100%) rename docs/gallery/{ => nested-hierarchy}/hierarchical-nested.svg (100%) create mode 100644 docs/gallery/parallel-edges-and-ports/README.md create mode 100644 docs/gallery/parallel-edges-and-ports/containment-parallel-edges-side-by-side.svg create mode 100644 docs/gallery/parallel-edges-and-ports/hierarchical-parallel-edges-side-by-side.svg rename docs/gallery/{ => parallel-edges-and-ports}/parallel-edges-into-compartment-box.svg (100%) rename docs/gallery/{ => parallel-edges-and-ports}/parallel-edges-merged.png (100%) rename docs/gallery/{ => parallel-edges-and-ports}/parallel-edges-merged.svg (100%) rename docs/gallery/{ => parallel-edges-and-ports}/parallel-edges-preserved-vertical.png (100%) rename docs/gallery/{ => parallel-edges-and-ports}/parallel-edges-preserved-vertical.svg (100%) rename docs/gallery/{ => parallel-edges-and-ports}/parallel-edges-preserved.png (100%) rename docs/gallery/{ => parallel-edges-and-ports}/parallel-edges-preserved.svg (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-horizontal.png (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-horizontal.svg (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-multi-connector-horizontal.png (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-multi-connector-horizontal.svg (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-multi-connector-vertical.png (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-multi-connector-vertical.svg (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-unlabeled-fan-out.png (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-unlabeled-fan-out.svg (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-vertical.png (100%) rename docs/gallery/{ => parallel-edges-and-ports}/ports-showcase-vertical.svg (100%) create mode 100644 docs/reqstream/ots/sonarscanner.yaml create mode 100644 docs/reqstream/rendering-layout/auto-layout-algorithm.yaml create mode 100644 docs/reqstream/rendering-layout/engine/edge-count-gap-widener.yaml create mode 100644 docs/reqstream/rendering-layout/engine/layout-tree-packer.yaml create mode 100644 docs/verification/ots/sonarscanner.md create mode 100644 docs/verification/rendering-layout/auto-layout-algorithm.md create mode 100644 docs/verification/rendering-layout/engine/edge-count-gap-widener.md create mode 100644 docs/verification/rendering-layout/engine/layout-tree-packer.md create mode 100644 src/DemaConsulting.Rendering.Layout/AutoLayoutAlgorithm.cs create mode 100644 src/DemaConsulting.Rendering.Layout/Engine/EdgeCountGapWidener.cs create mode 100644 src/DemaConsulting.Rendering.Layout/Engine/LayoutTreePacker.cs create mode 100644 test/DemaConsulting.Rendering.Layout.Tests/AutoLayoutAlgorithmTests.cs create mode 100644 test/DemaConsulting.Rendering.Layout.Tests/Engine/EdgeCountGapWidenerTests.cs create mode 100644 test/DemaConsulting.Rendering.Layout.Tests/Engine/LayoutTreePackerTests.cs diff --git a/.cspell.yaml b/.cspell.yaml index 147a63b..e15a72f 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -73,6 +73,10 @@ words: - collinearity - collinearly - pathfinding + - transversal + - transversally + - unwidened + - sonarscanner # Non-ASCII terms retained verbatim in extracted source comments - façade - Köpf diff --git a/.reviewmark.yaml b/.reviewmark.yaml index db5dec4..4e6c12e 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -381,6 +381,36 @@ reviews: - "docs/reqstream/rendering-layout/engine/engine.yaml" - "docs/design/rendering-layout/connector-router.md" + - id: Rendering-Layout-Engine-LayoutTreePacker + title: Review that Rendering Layout Engine LayoutTreePacker Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/engine/layout-tree-packer.yaml" + - "docs/design/rendering-layout/engine/layout-tree-packer.md" + - "docs/verification/rendering-layout/engine/layout-tree-packer.md" + - "src/DemaConsulting.Rendering.Layout/Engine/LayoutTreePacker.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/Engine/LayoutTreePackerTests.cs" + context: + - "docs/design/rendering-layout.md" + - "docs/design/rendering-layout/engine/engine.md" + - "docs/reqstream/rendering-layout.yaml" + - "docs/reqstream/rendering-layout/engine/engine.yaml" + + - id: Rendering-Layout-Engine-EdgeCountGapWidener + title: Review that Rendering Layout Engine EdgeCountGapWidener Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/engine/edge-count-gap-widener.yaml" + - "docs/design/rendering-layout/engine/edge-count-gap-widener.md" + - "docs/verification/rendering-layout/engine/edge-count-gap-widener.md" + - "src/DemaConsulting.Rendering.Layout/Engine/EdgeCountGapWidener.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/Engine/EdgeCountGapWidenerTests.cs" + context: + - "docs/design/rendering-layout.md" + - "docs/design/rendering-layout/engine/engine.md" + - "docs/design/rendering-layout/engine/containment-packer.md" + - "docs/design/rendering-layout/hierarchical-layout-algorithm.md" + - "docs/reqstream/rendering-layout.yaml" + - "docs/reqstream/rendering-layout/engine/engine.yaml" + # Rendering.Layout system-level units - id: Rendering-Layout-LayeredLayoutAlgorithm title: Review that Rendering Layout LayeredLayoutAlgorithm Implementation is Correct @@ -469,6 +499,19 @@ reviews: - "docs/design/rendering-layout.md" - "docs/reqstream/rendering-layout.yaml" + - id: Rendering-Layout-AutoLayoutAlgorithm + title: Review that Rendering Layout AutoLayoutAlgorithm Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/auto-layout-algorithm.yaml" + - "docs/design/rendering-layout/auto-layout-algorithm.md" + - "docs/verification/rendering-layout/auto-layout-algorithm.md" + - "src/DemaConsulting.Rendering.Layout/AutoLayoutAlgorithm.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/AutoLayoutAlgorithmTests.cs" + context: + - "docs/design/rendering-layout.md" + - "docs/reqstream/rendering-layout.yaml" + - "docs/design/rendering-layout/engine/layout-tree-packer.md" + # ===================================================================== # Rendering.Svg system # ===================================================================== @@ -683,6 +726,13 @@ reviews: - "docs/design/ots/sonarmark.md" - "docs/verification/ots/sonarmark.md" + - id: OTS-SonarScanner + title: Review that SonarScanner for .NET Provides Required Functionality + paths: + - "docs/reqstream/ots/sonarscanner.yaml" + - "docs/design/ots/sonarscanner.md" + - "docs/verification/ots/sonarscanner.md" + - id: OTS-VersionMark title: Review that VersionMark Provides Required Functionality paths: diff --git a/README.md b/README.md index 5442d46..dec3f69 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ extensible property system, and new algorithms, renderers, and options are added ## Features -- **Pluggable layout algorithms** — layered (Sugiyama-style), containment packing, and a recursive - hierarchical engine for compound (nested-container) graphs, each selected through the open property - system. +- **Pluggable layout algorithms** — layered (Sugiyama-style), containment packing, a recursive + hierarchical engine for compound (nested-container) graphs, and an auto-routing meta-algorithm that + picks the best fit per connected component, each selected through the open property system. - **Orthogonal edge routing** — axis-aligned connectors in all four flow directions, with obstacle avoidance around intervening containers. - **Named ports** — first-class, labelled attachment points on a node's boundary, including parallel @@ -29,6 +29,28 @@ extensible property system, and new algorithms, renderers, and options are added - **Multiple output formats** — SVG (zero external dependencies) plus PNG, JPEG, and WEBP via SkiaSharp, with light, dark, and print themes. +## Algorithm capabilities + +The four bundled layout algorithms cover different concerns; pick `auto` when a diagram's shape is +not known in advance, or select a leaf algorithm directly when it is. `auto` delegates every concern +to whichever leaf algorithm it routes a component to, so its result is only as good as that leaf's own +support for the row. + +| Concern | `layered` | `containment` | `hierarchical` | `auto` | +| --- | --- | --- | --- | --- | +| Horizontal gap widening by edge count | Yes | Yes | Yes | Delegates | +| Vertical gap widening by edge count | Partial | No | No | Delegates | +| Container auto-expansion | N/A | N/A | Yes | Delegates | +| Port/label space reservation | Yes | No | Yes | Delegates | +| Direction/orientation support | Yes | No | Yes | Delegates | +| Connectivity/component awareness | Yes | No | Yes | Delegates | +| Aspect-ratio/canvas-shape targeting | Partial | Yes | Partial | Delegates | +| Nested/recursive containment handling | No | No | Yes | Delegates | + +See the [user guide][link-user-guide] for the full capability matrix with one-line reasons per cell, +the `auto` delegation caveats, and guidance on when to call a specific algorithm directly instead of +`auto`. + ## Packages The library is split into focused packages so consumers take only what they need: diff --git a/ROADMAP.md b/ROADMAP.md index e61fe56..5a351a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -527,7 +527,8 @@ same-face crowding (no port-spacing-by-width work): outward and inward faces of the same boundary point, respectively. - The gallery entry must be wired into `GalleryCatalog`/`GalleryIndex` like existing examples, so - it renders via `gallery.ps1` and appears in `docs/gallery/README.md`. + it renders via `gallery.ps1` and appears in the appropriate group's `docs/gallery//README.md` + page (or the top-level `docs/gallery/README.md` index if a new group is warranted). ## Unify all text rendering onto a single `LayoutLabel`/`LayoutText` primitive diff --git a/docs/design/introduction.md b/docs/design/introduction.md index fbf9436..9de8f9d 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -33,7 +33,7 @@ software items: notation-geometry helpers. - **Rendering.Layout (System)** — the pluggable layout algorithms and the reusable geometric engines they are built from: the ELK-style layered pipeline plus the bundled `layered`, - `containment`, and `hierarchical` algorithms, orthogonal connector routing, and the default + `containment`, `hierarchical`, and `auto` algorithms, orthogonal connector routing, and the default layout facade. - **Rendering.Svg (System)** — the SVG renderer. - **Rendering.Skia (System)** — the SkiaSharp raster renderers (PNG, JPEG, and WEBP). @@ -86,8 +86,8 @@ understand, so unknown or not-yet-honored properties default harmlessly. New dia and output formats are introduced additively by implementing `ILayoutAlgorithm` or `IRenderer` and registering them — no existing contract changes. -The delivered subset implements the `layered`, `containment`, and `hierarchical` algorithms and the -SVG and SkiaSharp (PNG, JPEG, WEBP) renderers. +The delivered subset implements the `layered`, `containment`, `hierarchical`, and `auto` algorithms and +the SVG and SkiaSharp (PNG, JPEG, WEBP) renderers. ## Software Structure @@ -114,10 +114,13 @@ Rendering.Layout (System) │ ├── OrthogonalEdgeRouter (Unit) — orthogonal (channel) edge router │ ├── ContainmentPacker (Unit) — shelf packer for grouped/containment layout │ ├── InterconnectionLayoutEngine (Unit)— cross-edge routing among placed boxes -│ └── LayeredPipeline (Unit) — the ELK-style layered Sugiyama stage pipeline +│ ├── LayeredPipeline (Unit) — the ELK-style layered Sugiyama stage pipeline +│ ├── LayoutTreePacker (Unit) — shelf packer merging multiple placed LayoutTrees +│ └── EdgeCountGapWidener (Unit) — shared edge-count-to-gap corridor-width formula ├── LayeredLayoutAlgorithm (Unit) — the public layered ILayoutAlgorithm ├── ContainmentLayoutAlgorithm (Unit) — the public containment ILayoutAlgorithm ├── HierarchicalLayoutAlgorithm (Unit) — the recursive hierarchical engine +├── AutoLayoutAlgorithm (Unit) — the auto-routing meta-algorithm ├── ContainmentLayout (Unit) — public containment packing entry point ├── ConnectorRouter (Unit) — public edge-routing orchestration ├── EdgeRoutingOption (Unit) — the EdgeRouting option realization diff --git a/docs/design/ots/sonarscanner.md b/docs/design/ots/sonarscanner.md new file mode 100644 index 0000000..3dbbed8 --- /dev/null +++ b/docs/design/ots/sonarscanner.md @@ -0,0 +1,25 @@ +## SonarScanner for .NET Integration Design + +### Purpose + +SonarScanner for .NET (the `dotnet-sonarscanner` local tool) wraps the MSBuild-based build with +begin/end analysis steps that collect source metrics, test coverage, and static-analysis issues and +upload them to SonarCloud for the repository's project. It is the analysis producer that SonarMark's +quality report subsequently reads back from SonarCloud. + +### Features Used + +- **Begin analysis** (`dotnet sonarscanner begin --key --organization --token`) — starts an + analysis session before the build, instrumenting subsequent compiler and test runs. +- **End analysis** (`dotnet sonarscanner end --token`) — stops the analysis session after the build + and tests complete, uploading the collected metrics, coverage, and issues to SonarCloud. + +### Integration Pattern + +SonarScanner for .NET is installed as a .NET local tool via `.config/dotnet-tools.json` and invoked +as `dotnet sonarscanner` from the code-quality steps of `.github/workflows/build.yaml`. The begin +step runs before `dotnet build`/`dotnet test`, and the end step runs immediately after, using the +`SONAR_TOKEN` secret for authentication. Its inputs are the SonarCloud project key, organization, +and API token; its output is the analysis published to SonarCloud, which SonarMark then retrieves +to render the offline quality report. SonarScanner for .NET is not referenced by the delivered +Rendering packages. diff --git a/docs/design/rendering-layout.md b/docs/design/rendering-layout.md index 73f9998..b774a24 100644 --- a/docs/design/rendering-layout.md +++ b/docs/design/rendering-layout.md @@ -5,8 +5,8 @@ `DemaConsulting.Rendering.Layout` is the placement system for the Rendering stack. A caller supplies a `LayoutGraph` plus `LayoutOptions`; the system returns a placed `LayoutTree` of boxes and orthogonally routed connectors that downstream renderers can draw without layout knowledge. The system exposes -bundled algorithms for layered, containment, and hierarchical layout, built on reusable geometric -engines. +bundled algorithms for layered, containment, hierarchical, and auto (meta-algorithm) layout, built on +reusable geometric engines. The system is composed of one subsystem and a set of public algorithm and facade units: @@ -16,19 +16,21 @@ DemaConsulting.Rendering.Layout (System) │ ├── OrthogonalEdgeRouter (Unit) │ ├── ContainmentPacker (Unit) │ ├── InterconnectionLayoutEngine (Unit) -│ └── LayeredPipeline (Unit) +│ ├── LayeredPipeline (Unit) +│ └── LayoutTreePacker (Unit) ├── EdgeRoutingOption (Unit) ├── ConnectorRouter (Unit) ├── ContainmentLayout (Unit) ├── ContainmentLayoutAlgorithm (Unit) ├── HierarchicalLayoutAlgorithm (Unit) ├── DefaultLayout (Unit) -└── LayeredLayoutAlgorithm (Unit) +├── LayeredLayoutAlgorithm (Unit) +└── AutoLayoutAlgorithm (Unit) ``` - **Engine** — reusable, model-agnostic geometric engines (orthogonal routing, containment packing, - interconnection placement, and the ELK-style layered stage pipeline). Detailed in Engine Subsystem - Design. + interconnection placement, the ELK-style layered stage pipeline, and layout-tree packing). Detailed + in Engine Subsystem Design. - **EdgeRoutingOption** — routing-style configuration keys. Detailed in EdgeRoutingOption Unit Design. - **ConnectorRouter** — routes connectors among already placed boxes. Detailed in ConnectorRouter Unit Design. @@ -42,24 +44,29 @@ DemaConsulting.Rendering.Layout (System) Design. - **LayeredLayoutAlgorithm** — public layered algorithm. Detailed in LayeredLayoutAlgorithm Unit Design. +- **AutoLayoutAlgorithm** — public auto-routing meta-algorithm that splits a graph into connected + components, routes each to whichever bundled leaf algorithm best suits its shape, and packs the + results into one combined tree. Detailed in AutoLayoutAlgorithm Unit Design. The public algorithms implement `ILayoutAlgorithm` from Rendering.Abstractions and compose the Engine subsystem's model-agnostic geometry. The `DefaultLayout` facade resolves the requested algorithm from the bundled registry. The hierarchical algorithm composes leaf algorithms per container and routes -cross-container edges at the owning scope. +cross-container edges at the owning scope. The auto algorithm composes the other three bundled +algorithms per connected component and packs their independently-placed results with `LayoutTreePacker`. ## External Interfaces - **`ILayoutAlgorithm` implementations** — outbound; `LayeredLayoutAlgorithm`, - `ContainmentLayoutAlgorithm`, and `HierarchicalLayoutAlgorithm` each realize the Abstractions layout - contract, accepting a `LayoutGraph` plus `LayoutOptions` and returning a placed `LayoutTree`. Each - advertises an identifier (`layered`, `containment`, `hierarchical`) for registry resolution. + `ContainmentLayoutAlgorithm`, `HierarchicalLayoutAlgorithm`, and `AutoLayoutAlgorithm` each realize + the Abstractions layout contract, accepting a `LayoutGraph` plus `LayoutOptions` and returning a + placed `LayoutTree`. Each advertises an identifier (`layered`, `containment`, `hierarchical`, + `auto`) for registry resolution. - **`LayoutEngine` facade / bundled registry (DefaultLayout)** — outbound entry points; resolve and run the requested (or default) algorithm. - **`EdgeRoutingOption`** — outbound `LayoutProperty` keys that select connector routing style. - **Engine APIs (`OrthogonalEdgeRouter`, `ContainmentPacker`, `InterconnectionLayoutEngine`, - `LayeredPipeline`)** — internal geometric services composed by the public algorithms; not intended - for direct renderer use. + `LayeredPipeline`, `LayoutTreePacker`)** — internal geometric services composed by the public + algorithms; not intended for direct renderer use. ## Dependencies @@ -80,7 +87,7 @@ architectural segregation (IEC 62304 §5.3.3). ```text LayoutGraph + LayoutOptions │ - ▼ selected ILayoutAlgorithm (layered / containment / hierarchical) + ▼ selected ILayoutAlgorithm (layered / containment / hierarchical / auto) Engine geometry (layered pipeline, packing, routing) │ ▼ diff --git a/docs/design/rendering-layout/auto-layout-algorithm.md b/docs/design/rendering-layout/auto-layout-algorithm.md new file mode 100644 index 0000000..13e0bb2 --- /dev/null +++ b/docs/design/rendering-layout/auto-layout-algorithm.md @@ -0,0 +1,113 @@ +## AutoLayoutAlgorithm Unit Design + +Part of the Rendering Layout system. + +### AutoLayoutAlgorithm Purpose + +`AutoLayoutAlgorithm` is the bundled auto-routing meta-algorithm. It splits an input graph into its +connected top-level components, routes each component to whichever bundled leaf algorithm best suits +its shape, lays each component out independently, and packs the resulting placed sub-trees into one +combined `LayoutTree` with `LayoutTreePacker`. It lets a caller select `"auto"` once and get a +reasonable layout for a graph whose shape is not known in advance — a mixture of connected clusters, +nested containers, and unrelated singleton nodes — without having to inspect the graph and choose an +algorithm by hand. + +### AutoLayoutAlgorithm Data Model + +The class is a sealed `LayoutAlgorithmBase` subclass with no per-call state beyond three private, +reused leaf-algorithm instances (`HierarchicalLayoutAlgorithm`, `LayeredLayoutAlgorithm`, +`ContainmentLayoutAlgorithm`, all themselves stateless). It exposes the `AlgorithmId` constant +(`"auto"`) and returns it from the `Id` property. Its single behavior is +`ApplyCore(LayoutGraph graph, LayoutOptions options)`, which returns a `LayoutTree`. + +### AutoLayoutAlgorithm Methods + +`ApplyCore(graph, options)` rejects a null `options` with `ArgumentNullException` (the base class +rejects a null `graph`), then: + +1. **Component detection.** Every top-level node is assigned to a connected component using the + graph's top-level edges, resolved through a private `TryResolveOwner`-shaped helper that mirrors + `LayeredLayoutAlgorithm`'s and `HierarchicalLayoutAlgorithm`'s own node/port endpoint resolution. + The helper is replicated locally rather than extracted into a shared type, since each caller's + resolution serves a slightly different purpose and all three implementations are independently + small. +2. **Routing rule.** Each detected component is routed by shape: + - A component containing any node with `HasChildren` is routed to `HierarchicalLayoutAlgorithm` + (which recurses further into any nesting on its own), regardless of the component's size — a + single isolated container node still needs the hierarchical engine to lay out its children. + - A component with two or more nodes and no children anywhere in it (including a single node + carrying only a self-loop edge) is routed to `LayeredLayoutAlgorithm`, since it has genuine + connectivity for the layered engine's Sugiyama layering to exploit. + - A truly childless, edgeless singleton node carries no connectivity or nesting information a + layered or hierarchical layout could use, so every such singleton across the whole graph is + instead gathered into one shared bucket routed through `ContainmentLayoutAlgorithm`, which packs + unrelated peer boxes into a balanced block. +3. **Fast path: nothing to split.** When the routing rule produces exactly one group overall (either + a single non-singleton component, or every top-level node is a childless, edgeless singleton), the + graph is not split at all: it is delegated directly, unchanged, to that one leaf algorithm's + `ApplyCore`, so the result is byte-for-byte identical to invoking that algorithm directly. This + mirrors `HierarchicalLayoutAlgorithm`'s own flat-graph equivalence guarantee, and is the common + case: a single fully (or mostly) connected diagram never pays any splitting or copying cost. +4. **Genuine multi-group split.** When 2+ groups are produced, the algorithm captures the graph's own + cascaded options once (`graph.OverlayOnto(options)`), then resets the captured `CoreOptions.Algorithm` + value to `LayeredLayoutAlgorithm.AlgorithmId` before passing it down to each group's leaf algorithm + call. This reset is required because the captured options otherwise still carry this graph's own + `Algorithm` value (typically `"auto"` itself, however the caller selected this algorithm), and + `HierarchicalLayoutAlgorithm` re-reads `CoreOptions.Algorithm` from its own effective options to + resolve its own top scope's leaf algorithm — `"auto"` is never a registered leaf identifier there, so + without the reset a hierarchical-routed group throws a lookup error. Resetting it to the layered + default (the same default `HierarchicalLayoutAlgorithm` itself falls back to when nothing declares + an override) restores the cascade to exactly what an ordinary caller not using `"auto"` would see. + Each group is then built into its own freshly-constructed `LayoutGraph` (see Sub-graph Construction + below), laid out through its routed algorithm, and every resulting `LayoutTree` is packed into one + combined tree via `LayoutTreePacker.Pack` (see its own Unit Design document). + +### AutoLayoutAlgorithm Sub-Graph Construction + +When more than one group is produced, each group is laid out on its own freshly-built `LayoutGraph` +rather than a shared view of the original graph: the public `LayoutGraph`/`LayoutGraphNode` API offers +no way to insert an existing node instance into a different graph's node list, and +`LayoutGraphNode.Children` has no setter, so an original node cannot simply be attached, by reference, +to a new parent graph. Every node in a split-off component is therefore copied field-by-field — label, +shape, keyword, compartments, title height, rounded-corner radius, folder-tab dimensions, its named +ports, and (recursively) its entire nested `Children` subgraph — with edges re-added afterward once +every node and port in the component has a copy, so both direct and cross-container edge endpoints +resolve correctly regardless of nesting depth. + +**Known, disclosed limitation.** A node's or edge's own arbitrary `PropertyHolder` option overrides +(set with `node.Set(property, value)`, for example a per-node `CoreOptions.Algorithm` override on a +container) are not copied onto a split component's nodes: `PropertyHolder` exposes no generic API to +enumerate or copy an arbitrary set of overrides onto a different instance, and adding one purely to +support this rarely-hit multi-component path was judged out of scope. The graph-level overrides that +matter most in practice are unaffected, since the original graph's own cascaded options are captured +once, before splitting, and passed as the fallback options to every split component's leaf algorithm, +so a graph-level override still applies to every piece exactly as it would have applied to the whole. +Only a node- or edge-level override, specifically on a node that ends up copied into a split +component, would be silently dropped — an edge case a caller can avoid today by preferring graph-level +(or per-scope `Children`-level) overrides over per-node ones when also relying on `"auto"` routing. + +An empty graph yields an empty `LayoutTree` because the containment fast path handles the (vacuously +single-group) empty case and `ContainmentLayoutAlgorithm.ApplyCore` returns an empty `LayoutTree` for +an empty node list. + +### AutoLayoutAlgorithm Interactions + +`AutoLayoutAlgorithm` depends on `LayoutGraph`, `LayoutTree`, and related model types from +`DemaConsulting.Rendering.Abstractions`, and on `LayeredLayoutAlgorithm`, `ContainmentLayoutAlgorithm`, +`HierarchicalLayoutAlgorithm`, and `LayoutTreePacker` (Engine subsystem) from within the Layout +package. It is registered by `LayoutAlgorithms.CreateDefaultRegistry()` alongside the other three +bundled algorithms and is resolved by renderers through the layout registry like any other algorithm. + +### Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-AutoAlgorithm-Identity | AutoLayoutAlgorithm behavior described above | +| Rendering-Layout-AutoAlgorithm-RoutesConnectedCluster | AutoLayoutAlgorithm behavior described above | +| Rendering-Layout-AutoAlgorithm-RoutesContainer | AutoLayoutAlgorithm behavior described above | +| Rendering-Layout-AutoAlgorithm-RoutesSingletonBucket | AutoLayoutAlgorithm behavior described above | +| Rendering-Layout-AutoAlgorithm-FastPathEquivalence | AutoLayoutAlgorithm behavior described above | +| Rendering-Layout-AutoAlgorithm-MultiGroupPacking | AutoLayoutAlgorithm behavior described above | +| Rendering-Layout-AutoAlgorithm-CascadesOptions | AutoLayoutAlgorithm behavior described above | +| Rendering-Layout-AutoAlgorithm-EmptyGraph | AutoLayoutAlgorithm behavior described above | +| Rendering-Layout-AutoAlgorithm-Validation | AutoLayoutAlgorithm behavior described above | diff --git a/docs/design/rendering-layout/connector-router.md b/docs/design/rendering-layout/connector-router.md index 0d538d5..71b5370 100644 --- a/docs/design/rendering-layout/connector-router.md +++ b/docs/design/rendering-layout/connector-router.md @@ -104,6 +104,16 @@ this is unreachable today because `Orthogonal` is the only enum value, but guard - **Engine subsystem** (`OrthogonalEdgeRouter` unit) — the internal orthogonal path-finding engine invoked by `RouteWithStatus` for the `EdgeRouting.Orthogonal` style. See _OrthogonalEdgeRouter Unit Design_. +- **`NotationMetrics`** — the note-fold and folder-tab sizing constants (`NoteFoldFraction`, + `NoteFoldMaxSize`, `FolderTabMaxWidthFraction`, `FolderTabMinWidth`, `FolderLabelCharWidthFactor`) + consumed by `NoteGeometry` and `FolderGeometry` to compute each shape's connectable-extent cutouts + (the note's diagonal fold corner and the folder's top-edge tab). +- **`Themes`** — `Themes.Light`'s `FontSizeBody` and `LabelPadding` feed `FolderGeometry`'s fallback + folder-tab width formula when a box does not supply its own `FolderTabWidth` hint, approximating the + tab width a themed folder label would need. +- **`BoxMetrics`** — `BoxMetrics.FolderTabHeight(Themes.Light)` is `FolderGeometry`'s fallback folder-tab + height when a box does not supply its own `FolderTabHeight` hint, keeping the routing cutout consistent + with the renderer's own default folder-tab geometry. No OTS runtime component or shared package is consumed. diff --git a/docs/design/rendering-layout/containment-layout-algorithm.md b/docs/design/rendering-layout/containment-layout-algorithm.md index 4d34a85..6fdb576 100644 --- a/docs/design/rendering-layout/containment-layout-algorithm.md +++ b/docs/design/rendering-layout/containment-layout-algorithm.md @@ -36,8 +36,8 @@ followed by `LayoutLine` per routed edge). of their total area scaled by `CanvasAspectRatio`, widened to at least the widest box, and floored to a small positive value so the packer always receives a usable width — even for an empty graph. 3. **Packing.** Calls `ContainmentLayout.Pack` with the leaf boxes and a `ContainmentOptions` using the - derived width and the connector-aware `NodeSpacing` on both axes, obtaining the packed boxes and the - enclosing region size. + derived width, the connector-aware `NodeSpacing` on both axes, and an `EdgeCounts` map (see below), + obtaining the packed boxes and the enclosing region size. 4. **Connection building.** Builds one `Connection` per edge whose source and target are both top-level nodes — carrying the edge's `TargetEnd`, `LineStyle`, and `Label` — using the packed box that represents each endpoint. Edges referencing a node outside the graph's top-level nodes are skipped, @@ -55,6 +55,22 @@ followed by `LayoutLine` per routed edge). An empty graph yields an empty `LayoutTree` with a positive-size canvas, because the packer returns a padding-only region for no children and no connections are routed. +**Edge-count-aware horizontal gap widening.** Before packing, the algorithm builds the `EdgeCounts` +map by reusing the same positional-index map recorded during leaf-box conversion: for every edge whose +source and target are both top-level nodes (the same endpoints the connection-building step keeps), it +increments the count for the unordered index pair `(min, max)`. Passing that map to the packer widens +the horizontal gap between two peer boxes packed side by side on the same row to the connector-corridor +width for their edge count — via the shared `EdgeCountGapWidener` helper, whose formula +(`max(baseGap, 2·ConnectorClearance + (n − 1)·EdgeSpacing)`) mirrors the layered pipeline's +`BrandesKopfPlacer` corridor sizing — so a fan of parallel connectors gets distinct lanes instead of +crowding one channel. The widening is horizontal-only by deliberate design decision: the vertical gap +between rows is left untouched so a `Source`-over-`Target` vertical stack (the arrangement of the +existing `parallel-edges-into-compartment-box` gallery diagram) keeps byte-identical box positions, +and the row-wrap decision itself uses the un-widened gap so no wrap point moves. The +`containment-parallel-edges-side-by-side` gallery diagram (two tall, narrow peer boxes joined by eight +parallel edges, sized so the packer places them on one row) is the reproduction scenario that exercises +and visually demonstrates this widening. + ### ContainmentLayoutAlgorithm Error Handling Null `graph` or `options` throw `ArgumentNullException`. Edges with an out-of-graph endpoint are @@ -72,6 +88,11 @@ skipped rather than treated as errors. All other behavior is inherited from the - `ContainmentLayout` (same system) — packs the top-level boxes into rows within the derived width budget. - `ConnectorRouter` (same system) — routes the top-level connections around the packed boxes. +- `EdgeCountGapWidener` (same system, internal engine helper) — supplies the shared connector-corridor + width formula the algorithm uses (through `ContainmentLayout`'s `EdgeCounts` map) to widen the + horizontal gap between same-row peer boxes; the same helper is reused by + `HierarchicalLayoutAlgorithm`'s sibling-container widening pass, keeping the three call sites' + spacing byte-identical. ### ContainmentLayoutAlgorithm Callers @@ -96,3 +117,6 @@ routing, adapting their combined output to the public `ILayoutAlgorithm` contrac | Rendering-Layout-ContainmentAlgorithm-SkipsOutOfGraphEdges | ContainmentLayoutAlgorithm behavior described above | | Rendering-Layout-ContainmentAlgorithm-Validation | ContainmentLayoutAlgorithm behavior described above | | Rendering-Layout-ContainmentAlgorithm-HonorsScopeEdgeRouting | ContainmentLayoutAlgorithm behavior described above | +| Rendering-Layout-ContainmentAlgorithm-EdgeCountGapWidening | ContainmentLayoutAlgorithm behavior described above | +| Rendering-Layout-ContainmentAlgorithm-VerticalStackUnaffected | ContainmentLayoutAlgorithm behavior described above | +| Rendering-Layout-ContainmentAlgorithm-CorridorWidthFormula | EdgeCountGapWidener helper described above | diff --git a/docs/design/rendering-layout/containment-layout.md b/docs/design/rendering-layout/containment-layout.md index 1d6f9e9..47a43d0 100644 --- a/docs/design/rendering-layout/containment-layout.md +++ b/docs/design/rendering-layout/containment-layout.md @@ -17,10 +17,14 @@ here. The unit comprises the public static class plus two records: -- `ContainmentOptions(MaxContentWidth, HorizontalGap, VerticalGap, Padding)` — an immutable record - selecting the row-wrap content width and the spacing. `MaxContentWidth` is required; `HorizontalGap` - and `VerticalGap` default to `8.0` and `Padding` defaults to `12.0` logical pixels. The names and - defaults mirror ELK's content-area, `spacing.nodeNode`, and `padding` vocabulary. +- `ContainmentOptions(MaxContentWidth, HorizontalGap, VerticalGap, Padding, EdgeCounts)` — an immutable + record selecting the row-wrap content width and the spacing. `MaxContentWidth` is required; + `HorizontalGap` and `VerticalGap` default to `8.0` and `Padding` defaults to `12.0` logical pixels. + The names and defaults mirror ELK's content-area, `spacing.nodeNode`, and `padding` vocabulary. + `EdgeCounts` is an optional `IReadOnlyDictionary<(int First, int Second), int>` keyed by the unordered + index pair (`First < Second`) that carries the number of parallel connectors routed between two + adjacent same-row children; it defaults to `null`, in which case packing is byte-identical to the + prior behaviour. - `ContainmentResult(Width, Height, Children)` — an immutable record carrying the enclosing region size (including outer padding) and the input boxes repositioned to their packed, region-relative coordinates, in input order. @@ -46,6 +50,18 @@ within the reported region (which includes the outer padding on every side), pla the content width alone on its own row while widening the region to contain it, and returns a padding-only region for an empty input. +**Edge-count-aware horizontal gap widening.** When `EdgeCounts` is supplied, `Pack` passes it through +to `ContainmentPacker`, which widens the horizontal gap between two adjacent children that land on the +*same row* to the connector-corridor width for the edge count recorded for that index pair, using the +shared `EdgeCountGapWidener` formula (`max(baseGap, 2·ConnectorClearance + (n − 1)·EdgeSpacing)`). This +reserves a distinct routing lane for each of a fan of parallel connectors instead of crushing them into +one narrow channel, exactly the reservation the layered pipeline already makes between its columns. The +widening is deliberately scoped so existing behaviour is preserved on three axes: the row-wrap decision +is computed against the *un-widened* gap so no wrap point ever moves; a pair the wrap decision splits +across two rows is never widened (it is no longer an adjacent same-row pair); and the *vertical* gap +between rows is never touched, so a vertical stack keeps its exact prior geometry. A `null` `EdgeCounts` +(the default) or an edge count of zero or one leaves placement byte-identical. + ### ContainmentLayout Error Handling Null `children`, `options`, or a null child element throw `ArgumentNullException`. Packing behavior for @@ -72,4 +88,7 @@ draw. It is independent of the layered pipeline and can be used on any set of si | Rendering-Layout-ContainmentLayout-EmptyInput | ContainmentLayout behavior described above | | Rendering-Layout-ContainmentLayout-PreservesFields | ContainmentLayout behavior described above | | Rendering-Layout-ContainmentLayout-Defaults | ContainmentLayout behavior described above | +| Rendering-Layout-ContainmentLayout-EdgeCountGapWidening | ContainmentLayout behavior described above | +| Rendering-Layout-ContainmentLayout-EdgeCountGapWideningOptedOut | ContainmentLayout behavior described above | +| Rendering-Layout-ContainmentLayout-EdgeCountGapWideningRowScoped | ContainmentLayout behavior described above | | Rendering-Layout-ContainmentLayout-Validation | ContainmentLayout behavior described above | diff --git a/docs/design/rendering-layout/default-layout.md b/docs/design/rendering-layout/default-layout.md index 9911e32..19719d7 100644 --- a/docs/design/rendering-layout/default-layout.md +++ b/docs/design/rendering-layout/default-layout.md @@ -6,7 +6,7 @@ Part of the Rendering Layout system. `LayoutAlgorithms` and `LayoutEngine` form the batteries-included happy path: the smallest possible way to lay out a graph with the algorithm it declares. `LayoutAlgorithms` is a factory for a -`LayoutAlgorithmRegistry` pre-populated with the three bundled algorithms; `LayoutEngine` is a thin +`LayoutAlgorithmRegistry` pre-populated with the four bundled algorithms; `LayoutEngine` is a thin facade that resolves the declared algorithm and applies it. Together they turn "lay out my graph with whatever algorithm it declares" into one call that correctly handles both flat and nested graphs, with no registry assembly or engine choice required of the caller. Both units are additive: they compose the @@ -16,8 +16,9 @@ existing algorithms and change no existing behavior. Both units are static and hold no per-call state. `LayoutAlgorithms.CreateDefaultRegistry()` builds a fresh `LayoutAlgorithmRegistry` and registers `LayeredLayoutAlgorithm` (`"layered"`), -`ContainmentLayoutAlgorithm` (`"containment"`), and `HierarchicalLayoutAlgorithm` (`"hierarchical"`), -returning a new, independently mutable instance on each call. `LayoutEngine` exposes the +`ContainmentLayoutAlgorithm` (`"containment"`), `HierarchicalLayoutAlgorithm` (`"hierarchical"`), and +`AutoLayoutAlgorithm` (`"auto"`), returning a new, independently mutable instance on each call. +`LayoutEngine` exposes the `DefaultAlgorithmId` constant (`"hierarchical"`) and holds one private static `LayoutAlgorithmRegistry` built once from `CreateDefaultRegistry()`; because the bundled algorithms are stateless, that shared registry is safe to read (resolve) concurrently. @@ -73,8 +74,8 @@ declared algorithm identifier absent from the resolving registry surfaces the re `LayoutTree` types on the public `Layout` signature, plus `CoreOptions.Algorithm` used for algorithm-identifier resolution. - **Layout units** (`LayeredLayoutAlgorithm`, `ContainmentLayoutAlgorithm`, - `HierarchicalLayoutAlgorithm`) — the three bundled algorithms registered by the default registry. - See the respective Unit Design documents. + `HierarchicalLayoutAlgorithm`, `AutoLayoutAlgorithm`) — the four bundled algorithms registered by the + default registry. See the respective Unit Design documents. No OTS runtime component or shared package is consumed. @@ -96,7 +97,7 @@ application → `LayoutEngine` → bundled algorithms. ### DefaultLayout Interactions -`LayoutAlgorithms` depends on `LayoutAlgorithmRegistry` and the three bundled algorithm units. +`LayoutAlgorithms` depends on `LayoutAlgorithmRegistry` and the four bundled algorithm units. `LayoutEngine` depends on `LayoutAlgorithms`, `LayoutAlgorithmRegistry`, `LayoutGraph`, `LayoutOptions`, `LayoutTree`, and `CoreOptions`. Callers typically pair `LayoutEngine.Layout(...)` with an `IRenderer` (for example `SvgRenderer`) to go from graph to rendered output in two calls. diff --git a/docs/design/rendering-layout/engine/containment-packer.md b/docs/design/rendering-layout/engine/containment-packer.md index 7669f5e..2c93238 100644 --- a/docs/design/rendering-layout/engine/containment-packer.md +++ b/docs/design/rendering-layout/engine/containment-packer.md @@ -12,15 +12,17 @@ pack child elements inside a containing box in a compact, ordered grid. #### ContainmentPacker Data Model `ContainmentPacker` is a static class with no instance state. Inputs are a list of `PackItem` -records (each a `Width` and `Height`), a `maxContentWidth`, a `horizontalGap`, a `verticalGap`, and -a `padding`. The result is a `PackResult` record carrying the region `Width`, `Height`, and the -ordered list of `PackedRect` rectangles, one per input item in input order, each positioned relative -to the region origin `(0, 0)`. +records (each a `Width` and `Height`), a `maxContentWidth`, a `horizontalGap`, a `verticalGap`, a +`padding`, and an optional `edgeCounts` lookup (an unordered index-pair-to-count dictionary; `null` +by default) used to widen the gap between same-row neighbors carrying a fan of parallel connectors. +The result is a `PackResult` record carrying the region `Width`, `Height`, and the ordered list of +`PackedRect` rectangles, one per input item in input order, each positioned relative to the region +origin `(0, 0)`. #### ContainmentPacker Methods -`Pack(items, maxContentWidth, horizontalGap, verticalGap, padding)` computes the packing as a single -left-to-right shelf (row) pass: +`Pack(items, maxContentWidth, horizontalGap, verticalGap, padding, edgeCounts)` computes the packing +as a single left-to-right shelf (row) pass: 1. **Degenerate case.** An empty item list returns a region of `2 * padding` on each axis with no rectangles. @@ -31,8 +33,16 @@ left-to-right shelf (row) pass: right edge would exceed `padding + maxContentWidth`. If so, it drops to a new row (advancing the row top by the row height plus `verticalGap`), resets the cursor, and places the item there. Because the first-in-row item is exempt from the check, an item wider than the content width is - placed alone on its own row and the region width grows to contain it. -4. **Region sizing.** The total width is the widest row's right edge plus padding; the total height + placed alone on its own row and the region width grows to contain it. The wrap check always + compares against the un-widened cursor, so the optional gap widening in step 4 never shifts a + wrap point relative to a caller that supplies no `edgeCounts`. +4. **Optional same-row gap widening.** When the caller supplies `edgeCounts` and the current item + stays in the same row as its predecessor (no wrap occurred), the packer looks up the unordered + index pair `(i - 1, i)`. If present with a count greater than one, the horizontal gap between + those two items is widened (via `EdgeCountGapWidener.Widen`) so the fan of parallel connectors + between them gets distinct routing lanes; the gap is only ever widened, never narrowed, and a + `null` `edgeCounts` (the default) reproduces the legacy geometry byte-for-byte. +5. **Region sizing.** The total width is the widest row's right edge plus padding; the total height is the last row's bottom plus padding. Input order is preserved, and the left-to-right, no-backtracking placement is what guarantees that @@ -44,12 +54,21 @@ A null `items` argument throws `ArgumentNullException`. An empty item list retur region. No other input causes a throw; an oversized item is handled by the first-in-row exemption rather than by an error. +#### ContainmentPacker Callers + +`ContainmentPacker` is a leaf engine invoked by callers that pack child elements inside a +containing box: + +- **ContainmentLayout** — packs the child boxes of every container into rows, using the returned + rectangles to position children and the region size to size the container; also builds the + `edgeCounts` lookup from same-row parallel-connector counts so adjacent packed items get widened + gaps where a fan of connectors needs the room. See *ContainmentLayout Unit Design*. + #### ContainmentPacker Interactions -`ContainmentPacker` depends only on the `PackItem`, `PackedRect`, and `PackResult` value types -declared alongside it. It is a leaf engine invoked by callers that pack child elements inside a -containing box, using the returned rectangles to position children and the region size to size the -container. +`ContainmentPacker` depends on the `PackItem`, `PackedRect`, and `PackResult` value types declared +alongside it, and on `EdgeCountGapWidener` for the optional same-row gap-widening formula (see +*EdgeCountGapWidener Unit Design*). #### Requirements Traceability @@ -62,3 +81,4 @@ container. | Rendering-Layout-ContainmentPacker-OversizedItem | ContainmentPacker behavior described above | | Rendering-Layout-ContainmentPacker-EmptyInput | ContainmentPacker behavior described above | | Rendering-Layout-ContainmentPacker-SingleItem | ContainmentPacker behavior described above | +| Rendering-Layout-ContainmentPacker-NullItems | ContainmentPacker behavior described above | diff --git a/docs/design/rendering-layout/engine/edge-count-gap-widener.md b/docs/design/rendering-layout/engine/edge-count-gap-widener.md new file mode 100644 index 0000000..e1d5a06 --- /dev/null +++ b/docs/design/rendering-layout/engine/edge-count-gap-widener.md @@ -0,0 +1,70 @@ +### EdgeCountGapWidener Unit Design + +Part of the Rendering Layout system. + +#### EdgeCountGapWidener Purpose + +`EdgeCountGapWidener` widens a single node-to-node gap so a fan of parallel connectors routed +through it has room to spread into distinct orthogonal lanes instead of being crushed into one +narrow channel. It gives the containment packer and the hierarchical algorithm's post-placement +sibling-gap-widening pass the *same* corridor-width math the layered pipeline's `BrandesKopfPlacer` +already uses between adjacent columns, so all three widening rules stay byte-identical instead of +drifting apart through copy-paste. + +#### EdgeCountGapWidener Data Model + +`EdgeCountGapWidener` is an internal static class with no instance state. It exposes a single pure +function of two inputs — a `baseGap` (`double`) and an `edgeCount` (`int`) — and returns the widened +gap as a `double`. It has no fields, no constructors, and no mutable state, so it is safe for +concurrent use. + +#### EdgeCountGapWidener Methods + +`Widen(baseGap, edgeCount)` computes the corridor width a fan of `edgeCount` parallel connectors +needs and returns the larger of that corridor width and `baseGap`: + +1. **Corridor width.** The corridor width is `2 * LayeredLayoutMetrics.ConnectorClearance + + (edgeCount - 1) * LayeredLayoutMetrics.EdgeSpacing` — a clearance on each side of the fan plus one + slot-to-slot spacing for every gap between adjacent connectors. +2. **Floor at the base gap.** `Math.Max(baseGap, corridorWidth)` makes the widening strictly + additive: the method never narrows `baseGap`, only ever grows it. +3. **Degenerate edge counts.** An `edgeCount` of one yields zero inter-connector gaps, so the + corridor width collapses to `2 * ConnectorClearance`, which is at or below every caller's existing + base gap; the base gap wins and is returned unchanged. An `edgeCount` of zero (or a negative + count, which no caller produces but which the formula still tolerates) drives `(edgeCount - 1)` + negative, pulling the corridor width below `2 * ConnectorClearance` and, in practice, below the + base gap; `Math.Max` again floors the result at the base gap. Both degenerate cases therefore + leave a caller's pre-existing spacing exactly unchanged rather than requiring a separate guard + clause. + +#### EdgeCountGapWidener Error Handling + +`Widen` performs no argument validation and never throws: every `double` value of `baseGap` and +every `int` value of `edgeCount` (including zero and negative counts) is a valid input, handled by +the formula and the `Math.Max` floor described above rather than by a rejected-input error path. + +#### EdgeCountGapWidener Interactions + +`EdgeCountGapWidener` depends only on `LayeredLayoutMetrics.ConnectorClearance` and +`LayeredLayoutMetrics.EdgeSpacing` from `Engine.Layered`. It is consumed by two call sites that each +widen a gap in proportion to the number of parallel connectors sharing it: + +- **ContainmentPacker** widens the horizontal gap between two items on the same row when parallel + connectors are routed through that gap, so a fan of connectors between adjacent packed items gets + distinct routing lanes. See *ContainmentPacker Unit Design*. +- **HierarchicalLayoutAlgorithm** widens the horizontal gap between two sibling containers placed + side by side on its no-boundary-port sibling-gap-widening pass, in proportion to the number of + cross-container edges between that pair. See *HierarchicalLayoutAlgorithm Unit Design*. + +Both call sites size their gap from the number of edges their own connector-routing pass must fan +through it; extracting the formula here keeps the three widening rules (this helper and +`BrandesKopfPlacer`'s own corridor sizing) byte-identical and prevents the copy-paste drift that +would otherwise let one call site's spacing diverge from another's. + +#### Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-EdgeCountGapWidener-CorridorFormula | EdgeCountGapWidener behavior described above | +| Rendering-Layout-EdgeCountGapWidener-DegenerateCases | EdgeCountGapWidener behavior described above | +| Rendering-Layout-EdgeCountGapWidener-NeverNarrows | EdgeCountGapWidener behavior described above | diff --git a/docs/design/rendering-layout/engine/interconnection-layout-engine.md b/docs/design/rendering-layout/engine/interconnection-layout-engine.md index 8c9c660..ab839f8 100644 --- a/docs/design/rendering-layout/engine/interconnection-layout-engine.md +++ b/docs/design/rendering-layout/engine/interconnection-layout-engine.md @@ -23,9 +23,12 @@ with `ConnectorWaypoints`. #### InterconnectionLayoutEngine Methods -`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. The optional `nodeSpacing` argument (defaulting to the engine's original fixed constant) +`Place(nodes, edges, direction, nodeSpacing)` builds a `LayeredGraph` from the inputs, wraps the +default stage sequence with `Engine.Layered.ComponentPacker.WithDefaultStages(nodeSpacing)` for the +requested `direction` (defaulting to Right), and runs it — so a disconnected input is automatically +split into its connected components, each laid out independently, and packed without overlap, with no +special action required by the caller (see the Layered Pipeline Unit Design document's `ComponentPacker` +section). 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 diff --git a/docs/design/rendering-layout/engine/layered-pipeline.md b/docs/design/rendering-layout/engine/layered-pipeline.md index 3af2aa7..d2bfb65 100644 --- a/docs/design/rendering-layout/engine/layered-pipeline.md +++ b/docs/design/rendering-layout/engine/layered-pipeline.md @@ -124,13 +124,30 @@ The default stage sequence added by `AddDefaultStages` runs in this order: for the requested direction. The Right direction is the identity; Down, Left, and Up are rotations or flips. It also normalizes the input node axes at the start of `Run`. -`ComponentPacker` is an optional composite stage added explicitly by callers that lay out potentially -disconnected graphs. It splits the graph into connected components, runs an inner stage sequence on -each, and packs the results without overlap in a deterministic order, translating each component's -boxes and waypoints together. Each component is laid out through a freshly constructed child -`LayeredGraph`, which copies the parent graph's `BackEdgeEntryApproach` so a caller-customized -reversed-edge clearance is honored consistently whether the input graph is packed into one component -or several. +`ComponentPacker` is a composite stage that splits the graph into connected components, runs an inner +stage sequence on each, and packs the results without overlap in a deterministic order, translating +each component's boxes and waypoints together. It begins by calling `AxisTransform.NormalizeInputAxes` +on the supplied graph, exactly as `LayeredLayoutPipeline.Run` does before its own stage sequence, so it +remains a fully self-contained stage regardless of call site. `NormalizeInputAxes` is idempotent — a +`LayeredGraph.InputAxesNormalized` flag guards the one-time Down/Up axis swap — so composing +`ComponentPacker` as an inner stage of a `LayeredLayoutPipeline` that already normalized the same graph +is safe and does not double-swap (and thereby undo) the Down/Up axis normalization. Each component is +laid out through a freshly constructed child `LayeredGraph`, which copies the parent graph's +`BackEdgeEntryApproach` so a caller-customized reversed-edge clearance is honored consistently whether +the input graph is packed into one component or several. `InterconnectionLayoutEngine.Place` — and +therefore the public `LayeredLayoutAlgorithm` — wraps the default stage sequence with +`ComponentPacker.WithDefaultStages(...)` +unconditionally, so every disconnected input laid out through the layered algorithm is automatically +split, laid out per component, and packed; a caller never has to add this stage explicitly to get +component-aware packing. `HierarchicalLayoutAlgorithm`'s own per-level flat runs (`RunRecursive` and +`AddRecursiveStages`) deliberately do *not* route through `ComponentPacker`: a hierarchical scope's +flat level is a single level of one container's already-grouped children, and the flat-graph +equivalence guarantee that `HierarchicalLayoutAlgorithm` must preserve (byte-identical output for an +unnested graph) depends on each level running the exact same stage sequence `LayeredLayoutAlgorithm` +runs directly — adding `ComponentPacker` only inside the hierarchical recursion, and not at +`LayeredLayoutAlgorithm`'s own entry point (before this change), would have made the two diverge for a +disconnected flat graph. Wiring `ComponentPacker` into `InterconnectionLayoutEngine.Place` itself keeps +both call paths consistent without special-casing the hierarchical recursion. #### Layered Pipeline Boundary Ports @@ -258,6 +275,7 @@ public layout result contract. | Rendering-Layout-LayeredPipeline-LongEdgeJoining | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-ComponentPacking | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-PackedComponentsBackEdgeApproach | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-ComponentPackingWiredIntoPlace | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-SharedState | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-InputValidation | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-ShapeAwareAnchors | Layered pipeline behavior described above | diff --git a/docs/design/rendering-layout/engine/layout-tree-packer.md b/docs/design/rendering-layout/engine/layout-tree-packer.md new file mode 100644 index 0000000..c5eb144 --- /dev/null +++ b/docs/design/rendering-layout/engine/layout-tree-packer.md @@ -0,0 +1,87 @@ +### LayoutTreePacker Unit Design + +Part of the Rendering Layout system. + +#### LayoutTreePacker Purpose + +`LayoutTreePacker` is a reusable shelf-packer that merges several independently-placed `LayoutTree`s +(each already a complete, self-contained canvas, typically produced by routing one connected component +of a graph through a different bundled leaf algorithm) into a single combined `LayoutTree`. It is the +`LayoutTree`-level counterpart to `Engine.Layered.ComponentPacker`: both split a disconnected input +into independently-laid-out pieces and pack the pieces into one non-overlapping arrangement using the +same greedy shelf heuristic. They are legitimately distinct, small implementations rather than true +duplication, because they operate on different types at different levels: `ComponentPacker` works on +the layered engine's internal `Rect`/`Point2D` representation for components that all share one +algorithm (the layered pipeline), while `LayoutTreePacker` works on already-rendered `LayoutTree`s that +may have come from *different* algorithms entirely (for example one component routed through +`layered` and another through `hierarchical`) — exactly the shape `AutoLayoutAlgorithm` needs for its +per-component routing. + +#### LayoutTreePacker Data Model + +`LayoutTreePacker` is an internal static class with no instance state. Input is an +`IReadOnlyList` (the sub-trees to merge), a `spacing` (gap kept between adjacent packed +sub-trees), and an `aspect` (target width-to-height multiplier for the packed arrangement). The result +is a single combined `LayoutTree`. + +#### LayoutTreePacker Methods + +`Pack(trees, spacing, aspect)` rejects a null `trees` argument with `ArgumentNullException`, then: + +1. **Degenerate cases.** An empty list returns a zero-size, empty `LayoutTree`. A single-tree list is + returned unchanged, since a lone tree needs no translation or merging. +2. **Shelf packing.** Each sub-tree's overall `Width`/`Height` is treated as one packable item and + placed by a greedy shelf heuristic (target row width = the wider of the widest single tree and + `sqrt(totalArea) * aspect`), matching the heuristic `Engine.Layered.ComponentPacker` uses for its own + packing. +3. **Recursive translation.** Every node in a packed sub-tree is shifted by that sub-tree's assigned + shelf offset, recursively: a `LayoutBox`'s own `X`/`Y` and every nested `LayoutBox.Children` node, a + `LayoutLine`'s every `Waypoints` point, and a `LayoutPort`'s `CentreX`/`CentreY`. `LayoutTree` + coordinates are absolute (not parent-relative), so translating a placed sub-tree requires this + recursive walk rather than a single offset applied once at the root. +4. **Combined result.** The packed region's overall width/height (from the shelf layout) and the + concatenated, translated node lists from every sub-tree (in input order) are assembled into one + combined `LayoutTree`. + +#### LayoutTreePacker Error Handling + +A null `trees` argument throws `ArgumentNullException`. `TranslateNode` switches over the closed set +of `LayoutNode` subtypes the three bundled leaf algorithms (`layered`, `hierarchical`, `containment`) +are confirmed to emit — `LayoutBox`, `LayoutLine`, and `LayoutPort` (verified by grepping every +`new LayoutBox(`/`new LayoutLine(`/`new LayoutPort(` construction site across `LayeredLayoutAlgorithm.cs`, +`HierarchicalLayoutAlgorithm.cs`, and `ContainmentLayoutAlgorithm.cs`). An unrecognized node type throws +`NotSupportedException` instead of being silently skipped — a deliberate divergence from the renderer +convention documented on `LayoutNode` ("unknown subtypes should be skipped for forward compatibility"): +a renderer skipping an unknown node still draws every other node correctly, but a packer silently +leaving an unknown node's coordinates untranslated would place it at the wrong (pre-pack) position with +no visible sign of the error — a worse failure mode than a hard, immediate exception naming the +offending type. + +#### LayoutTreePacker Design Notes: Out of Scope + +The shelf packer always wraps a component onto the next row once the running row width would exceed +the target, which suits the compact rectangular components every bundled leaf algorithm produces. A +future enhancement could add a force-directed/spring-relaxation fallback that packs many small, sparse +components more organically (for example a large field of unrelated singleton nodes) instead of a +strict grid of shelves, but that is a materially different, self-contained algorithm and is explicitly +out of scope for this type today. + +#### LayoutTreePacker Interactions + +`LayoutTreePacker` depends only on the `LayoutTree`, `LayoutNode`, `LayoutBox`, `LayoutLine`, and +`LayoutPort` types from `DemaConsulting.Rendering.Abstractions` / `DemaConsulting.Rendering`. It is +consumed solely by `AutoLayoutAlgorithm`, which packs one placed sub-tree per routed component into a +single combined tree. + +#### Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-LayoutTreePacker-PacksMultipleTrees | LayoutTreePacker behavior described above | +| Rendering-Layout-LayoutTreePacker-TranslatesCoordinates | LayoutTreePacker behavior described above | +| Rendering-Layout-LayoutTreePacker-TranslatesNestedChildren | LayoutTreePacker behavior described above | +| Rendering-Layout-LayoutTreePacker-TranslatesWaypoints | LayoutTreePacker behavior described above | +| Rendering-Layout-LayoutTreePacker-DegenerateCases | LayoutTreePacker behavior described above | +| Rendering-Layout-LayoutTreePacker-UnsupportedNodeType | LayoutTreePacker behavior described above | +| Rendering-Layout-LayoutTreePacker-PreservesWarnings | LayoutTreePacker behavior described above | +| Rendering-Layout-LayoutTreePacker-Validation | LayoutTreePacker behavior described above | diff --git a/docs/design/rendering-layout/engine/orthogonal-edge-router.md b/docs/design/rendering-layout/engine/orthogonal-edge-router.md index 7f3971e..285c62f 100644 --- a/docs/design/rendering-layout/engine/orthogonal-edge-router.md +++ b/docs/design/rendering-layout/engine/orthogonal-edge-router.md @@ -83,10 +83,10 @@ No OTS runtime component or shared package is consumed. orthogonally through an obstacle field: - **ConnectorRouter** — dispatches to `RouteWithStatus` for every connection routed under the - `EdgeRouting.Orthogonal` style. See _ConnectorRouter Unit Design_. + `EdgeRouting.Orthogonal` style. See *ConnectorRouter Unit Design*. - **LayeredPipeline** (`OrthogonalRouter` stage) — routes individual layered-pipeline edges through - the same engine so pipeline routes and free-form routes share one implementation. See _Layered - Pipeline Unit Design_. + the same engine so pipeline routes and free-form routes share one implementation. See *Layered + Pipeline Unit Design*. The `Crossed` flag returned by `RouteWithStatus` feeds each caller's layout-warning handling. @@ -108,3 +108,4 @@ route individual connectors; the `Crossed` flag feeds their layout-warning handl | Rendering-Layout-OrthogonalEdgeRouter-CostBands | OrthogonalEdgeRouter behavior described above | | Rendering-Layout-OrthogonalEdgeRouter-NoWaypointRevisit | OrthogonalEdgeRouter behavior described above | | Rendering-Layout-OrthogonalEdgeRouter-AvoidsExtendedSoftOverlap | OrthogonalEdgeRouter behavior described above | +| Rendering-Layout-OrthogonalEdgeRouter-AvoidsConnectorCrossing | OrthogonalEdgeRouter behavior described above | diff --git a/docs/design/rendering-layout/hierarchical-layout-algorithm.md b/docs/design/rendering-layout/hierarchical-layout-algorithm.md index 17ffeb7..9eeec52 100644 --- a/docs/design/rendering-layout/hierarchical-layout-algorithm.md +++ b/docs/design/rendering-layout/hierarchical-layout-algorithm.md @@ -88,6 +88,26 @@ overrides win over the supplied options, exactly like every nested scope), and c and the composed boxes followed by the leaf-routed lines, any leaf-emitted port anchors, and the cross-container lines. +**No-boundary-port sibling-container gap widening.** On the no-boundary-port path (step 7 above), +before routing the cross-container edges, `LayoutScope` runs a post-placement widening pass +(`WidenSiblingContainerGaps`) over the composed boxes. It counts the cross-container edges between each +unordered pair of direct-member containers (resolving each endpoint to the direct member that owns it, +exactly as the router does) and, for a pair placed genuinely side by side — vertically overlapping, +with no third box in the channel between them — widens the horizontal gap between the two to the +connector-corridor width for that edge count. The width comes from the shared `EdgeCountGapWidener` +helper, the same formula the containment packer and the layered pipeline's `BrandesKopfPlacer` use, so +a fan of parallel cross-container connectors gets distinct routing lanes — spacing the per-scope leaf +algorithm alone cannot reserve, because those edges never appear in the sized view it lays out. The +pass applies each widening as a rigid half-plane shift (every box, line, and port at or right of the +cut moves right by the summed extra), returning the inputs unchanged when no pair qualifies, so a pair +joined by zero or one cross-container edge stays byte-identical. The widening is horizontal-only and is +confined to this no-boundary-port path; extending it to the boundary-port combined pass below is +explicitly deferred (see ROADMAP.md) because that path composes geometry through the recursive +merge-region pipeline rather than the `ComposeBoxes` path this post-placement pass operates on. The +`hierarchical-parallel-edges-side-by-side` gallery diagram (two peer containers, each with a +compartment-bearing child, joined by eight cross-container edges) is the reproduction scenario that +exercises and visually demonstrates this widening. + **Boundary (delegation) port combined pass.** After the leaf pass places a scope, `LayoutScope` collects that scope's boundary ports with `HierarchyMergeRegionBuilder` (part of the layered-pipeline unit, in `Engine/Layered`): a container's port is a boundary port when an edge inside that container's @@ -173,7 +193,9 @@ would be in a flat graph. scope's cascaded effective options snapshot. - **Layout units** — `LayeredLayoutAlgorithm` and `ContainmentLayoutAlgorithm` as bundled leaf algorithms registered in the default registry, `ConnectorRouter` for LCA cross-container edge - routing, and `HierarchyMergeRegionBuilder`, `MergeRegionGraphAssembler`, the recursive + routing, `EdgeCountGapWidener` (internal engine helper) for the shared connector-corridor width the + no-boundary-port sibling-gap widening pass reserves, and `HierarchyMergeRegionBuilder`, + `MergeRegionGraphAssembler`, the recursive `LayeredLayoutPipeline`, `MergeRegionDecomposer`, and `BoundaryPortResolver.FaceForDirection` (layered-pipeline unit, `Engine/Layered`) for boundary-port detection, the combined recursive pass, and its projection back to per-scope geometry. See *ConnectorRouter Unit Design* @@ -214,9 +236,16 @@ renderers and callers through the layout registry under the `"hierarchical"` ide | Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-HierarchyHandling | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-CrossContainerEdge | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-SiblingContainerGapWidening | Widening pass described above | +| Rendering-Layout-HierarchicalLayout-SiblingContainerGapWideningSingleEdgeUnaffected | Widening pass described above | +| Rendering-Layout-HierarchicalLayout-SiblingGapWideningPortPathUnaffected | Widening pass described above | | Rendering-Layout-HierarchicalLayout-BoundaryPortDelegation | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-BoundaryPortFanMerge | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-IndependentBoundaryPorts | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-BoundaryPortOrthogonal | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-CascadesOptions | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-CascadesAllProperties | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-HonorsScopeEdgeRouting | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-ValidatesGraph | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-ValidatesOptions | HierarchicalLayoutAlgorithm behavior described above | diff --git a/docs/gallery/README.md b/docs/gallery/README.md index f43a081..7431c6c 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -1,207 +1,76 @@ # Rendering Gallery -This gallery showcases what the Rendering library can produce. Every image is generated by the gallery test project -directly from the public API, so this page doubles as an end-to-end rendering smoke test. +See what the Rendering library can do with your own data. Every image below is generated by the gallery test project +directly from the public API — this page doubles as an end-to-end rendering smoke test, not a set of hand-drawn +mock-ups. Regenerate every page and image by running `./gallery.ps1` from the repository root. -Regenerate this page and its images by running `./gallery.ps1` from the repository root. +Bring a graph shaped like yours and find its group below: each one takes the *same kind* of input data and lays it out +with several of the bundled algorithms side by side, so you can see whether the automatic `auto` choice looks right for +your case or whether you'd rather call a specific algorithm directly. The one exception is **Appearance and themes**, +which is grouped by visual capability instead of input shape. **Layout regressions** is kept separate too, as a +permanent record of bugs that were fixed and must stay fixed. -## Layout algorithms +Each group's own page has the full diagram set with detailed captions explaining what each one demonstrates and, where +relevant, which bug it guards against. This page just points the way and shows a taste of each. -The bundled algorithms, each laying out the same kind of node-and-edge graph in its own style. Select one with the -algorithm option and let the engine place the boxes and route the edges. +| Group | What it's about | -![Layered pipeline diagram](layered-pipeline.svg) +| --- | --- | -A directed pipeline laid out left to right by the layered algorithm. +| [Flow pipeline](flow-pipeline/README.md) | A single connected, directed flow — pipelines and direction changes | +| [Connectivity and clusters](connectivity-and-clusters/README.md) | Connected vs. disconnected input and clusters | +| [Nested hierarchy](nested-hierarchy/README.md) | Parent/child containment and boundary-port delegation | +| [Parallel edges and ports](parallel-edges-and-ports/README.md) | Many edges between few boxes, named/boundary ports | +| [Appearance and themes](appearance-and-themes/README.md) | Box shapes, keywords, compartments, and built-in themes | +| [Layout regressions](layout-regressions/README.md) | Permanent visual proof that fixed layout bugs stay fixed | +| [Custom rendering](custom-rendering/README.md) | Hand-built LayoutTrees, bypassing the layout engine entirely | -![Containment packed diagram](containment-packed.svg) +## A taste of what's inside -Sibling boxes packed compactly by the containment algorithm. +![Layered pipeline diagram](flow-pipeline/layered-pipeline.svg) -![Hierarchical nested diagram](hierarchical-nested.svg) +A directed pipeline laid out left to right by the layered algorithm. See [Flow pipeline](flow-pipeline/README.md) for +the full set. -A container node holding a nested child graph, with a cross-container edge. - -## Layout regressions - -Small graphs that reproduce and pin down a specific layout bug once fixed, kept as permanent visual and numeric evidence -that it stays fixed. - -![A hub column with an isolated node beside a routing-corridor dummy](isolated-node-layer-gap.svg) - -Regression coverage for the isolated-node layer-gap fix: a genuinely isolated node (Isolated, zero edges) shares its -layer with a dummy bend point created by an unrelated long edge (LongSource to LongTarget). The crossing minimizer now -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. - -![Nine edges docking on a compartment box without crossing its interior](parallel-edges-into-compartment-box.svg) - -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 -overriding its own direction independently of its parent. A rightward flow arranges the layers left-to-right for block -and pipeline diagrams; a downward flow arranges them top-to-bottom for action flows and state machines, swapping each -node's width and height so layer spacing follows node height. - -![Directed flow laid out left to right](direction-right.svg) - -The default rightward direction: layers progress left-to-right. - -![The same directed flow laid out top to bottom](direction-down.svg) - -The downward direction: the same graph's layers progress top-to-bottom. - -![A nested container flowing downward inside an outer rightward flow](mixed-direction-nested.svg) +![A nested container flowing downward inside an outer rightward flow](flow-pipeline/mixed-direction-nested.svg) A container's own direction override is honored independently of its parent: the outer flow runs left-to-right while the -nested container runs top-to-bottom. - -## Edge routing - -Orthogonal connectors step around the boxes between their endpoints instead of cutting through them. +nested container runs top-to-bottom. See [Flow pipeline](flow-pipeline/README.md) for the full set. -![Orthogonal edge routed around an obstacle](orthogonal-obstacle.svg) +![Three disconnected two-node clusters, each routed and packed +independently](connectivity-and-clusters/auto-multiple-disconnected-clusters.svg) -A connector routed orthogonally around an intervening container box. +The same three-cluster graph routed through "auto": each cluster is its own connected component, so each is laid out by +the layered algorithm independently and the three results are packed into one combined canvas. See [Connectivity and +clusters](connectivity-and-clusters/README.md) for the full set. -## Themes +![Twelve small, wide boxes packed into a balanced multi-column +block](connectivity-and-clusters/containment-many-small-wide-boxes.svg) -One representative diagram rendered with each of the three built-in themes, showing how the theme controls colours, -stroke, and corner style without touching the layout. These are rendered through the raster path to PNG so each carries -a solid theme background. +Twelve identically-sized, wide boxes: the column-count-based content-width candidate keeps the containment algorithm +from packing them into one long, narrow column, wrapping them into a balanced grid of columns instead. See [Connectivity +and clusters](connectivity-and-clusters/README.md) for the full set. -![Representative diagram in the light theme](theme-light.png) +![A boundary port delegating through a nested boundary port to a +leaf](nested-hierarchy/boundary-ports-showcase-deep-chain.svg) -The light theme, suited to on-screen viewing. - -![Representative diagram in the dark theme](theme-dark.png) - -The dark theme, suited to dark-mode viewing. - -![Representative diagram in the print theme](theme-print.png) - -The print theme, optimised for black-and-white output. - -## Raster output - -The layout-algorithm diagrams above are rendered as SVG with the dark theme; here the same two diagrams are rendered -through the SkiaSharp raster path to PNG, proving multi-format output. - -![Layered pipeline diagram as PNG](layered-pipeline.png) - -The layered pipeline rendered to a raster PNG image. - -![Hierarchical nested diagram as PNG](hierarchical-nested.png) - -The hierarchical nested diagram rendered to a raster PNG image. - -## Box appearance - -A node's Shape, Keyword, and Compartments properties select the box outline, an italicized keyword line, and labelled -feature sections, all through the plain input graph model — no downstream renderer-specific code required. This is -generic block-diagram notation; SysML is just one modeling language that uses it. - -![A folder container with two boxes carrying a keyword, one also compartmented, joined by an edge](box-appearance.svg) - -A folder container holding two boxes with a keyword line — one also with a labelled compartment — joined by a decorated -edge. - -## Shape-aware connectors - -A box's Shape can make its true outline diverge from its plain bounding rectangle — a folder's tab, a note's folded -corner, a rounded rectangle's corners. The router keeps connectors off those non-connectable regions and projects each -anchor down to the shape's actual drawn outline, so every connector visibly touches the shape it targets. +A three-level delegation chain: a sibling approaches an outer container's boundary port, which delegates inward to a +nested container's own boundary port, which delegates again to the innermost leaf. Both boundary crossings carry an +outward external and an inward internal label, and the whole chain is routed orthogonally in one combined recursive pass +with no diagonal shortcut at either boundary. See [Nested hierarchy](nested-hierarchy/README.md) for the full set. -![An external node connected into a folder top face, clear of the tab](folder-top-face-anchor.svg) +![Nine edges docking on a compartment box without crossing its +interior](parallel-edges-and-ports/parallel-edges-into-compartment-box.svg) -An edge approaching a folder container from above: the connector avoids the tab and anchors on the folder's recessed top -edge instead of floating above it. +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. See [Parallel edges +and ports](parallel-edges-and-ports/README.md) for the full set. -![One of each container shape side by side, each holding content](shape-gallery.svg) +![One of each container shape side by side, each holding content](appearance-and-themes/shape-gallery.svg) Every Shape value side by side, each with content appropriate to it: rectangle and rounded-rectangle boxes with a keyword and a compartment, a folder holding a nested child, and a note holding free-form text — every shape reserves -enough space so its content never overlaps the tab or the folded corner. - -## Parallel edges and named ports - -The layered algorithm's Phase 1 flat-graph support for multiple parallel connectors between the same two boxes, and for -named ports attached to a specific, labelled location on a node's boundary. Parallel edges either collapse to one -rendered connector (the default) or each keep their own independently-routed line, selected with the MergeParallelEdges -option; each node's ContentInset margins are auto-computed from its ports' measured label widths so port text never -overlaps the box's own content. - -![Three parallel connectors between the same two boxes, independently routed](parallel-edges-preserved.svg) - -MergeParallelEdges set to false: all three parallel connectors survive, each with its own label. - -![The same three parallel connectors on a downward-flowing pair of boxes](parallel-edges-preserved-vertical.svg) - -The companion vertical-flow case: with a downward Direction the three parallel connectors anchor on the boxes' top and -bottom faces instead of their left and right faces, and each box's WIDTH (not height) auto-grows to fit the widened lane -spacing, since PortDistributor spreads anchors on a top/bottom face horizontally. - -![The same three parallel connectors collapsed to a single line](parallel-edges-merged.svg) - -The default MergeParallelEdges (true): the three parallel connectors collapse to a single rendered line, and its -midpoint label is omitted entirely (not any single surviving connector's label) since a reader could not tell which of -the three collapsed connectors a kept label would have belonged to. - -![A hub node with a named port on each of its left and right sides](ports-showcase-horizontal.svg) - -Left/right named ports on a rightward-flowing hub node; the long left-side incoming label auto-computes a widened -ContentInsetLeft margin, measured with the Skia-backed text measurer. - -![A hub node with a named port on each of its top and bottom sides](ports-showcase-vertical.svg) - -The companion top/bottom case: a downward-flowing hub node, whose ports anchor on its top and bottom faces instead. - -![A hub node with two named ports on each of its left and right sides](ports-showcase-multi-connector-horizontal.svg) - -Same-face crowding with two independently-labelled ports per side (one deliberately long): PortDistributor spreads both -anchors on each face without collapsing them onto one row, and the hub's title stays clear of both stacked rows on -either side. - -![A hub node with two named ports on each of its top and bottom sides](ports-showcase-multi-connector-vertical.svg) - -The companion top/bottom case: two ports per face spread horizontally instead of vertically, proving the same crowding -and title-collision protection when PortDistributor works along the cross axis of a downward flow. - -![A titled hub node with several unlabeled ports fanning out to four other boxes](ports-showcase-unlabeled-fan-out.svg) - -A face with several ports carrying no label or text at all still grows the box tall enough to keep them from bunching -together — the growth floor applies unconditionally to any face with two or more anchors, not only labeled ones. - -## Boundary and delegation ports - -The hierarchical engine's support for boundary (delegation) ports: a container may expose a named port carrying BOTH an -external and an internal label at one shared physical anchor on its boundary. An external approach edge from a sibling -reaches the anchor from outside, while one or more internal delegation edges relay the connection inward to the -container's nested children. The container and its children are laid out in one combined recursive pass, and every -converging edge — external approach and internal delegation alike — is routed through the orthogonal corridor router -onto that single shared anchor, with the external label reading outward and the internal label reading inward. - -![A sibling joined to a container's boundary port delegating to two children](boundary-ports-showcase-horizontal.svg) - -A rightward-flowing container exposes one boundary port on its left face carrying both a 'command' external label -(reading outward) and a 'dispatch' internal label (reading inward) at the same shared anchor. The external approach edge -from the sibling and both internal delegation edges (internal fan-out to two nested children) are routed orthogonally -onto that one anchor. - -![Two siblings joined to a container's top-face boundary port and one child](boundary-ports-showcase-vertical.svg) - -The companion downward-flowing case: the boundary port anchors on the container's top face, with external fan-out (two -sibling approach edges) both routed orthogonally onto the one shared anchor, which then delegates inward to the single -nested child. - -![A boundary port delegating through a nested boundary port to a leaf](boundary-ports-showcase-deep-chain.svg) - -A three-level delegation chain: a sibling approaches an outer container's boundary port, which delegates inward to a -nested container's own boundary port, which delegates again to the innermost leaf. Both boundary crossings carry an -outward external and an inward internal label, and the whole chain is routed orthogonally in one combined recursive pass -with no diagonal shortcut at either boundary. +enough space so its content never overlaps the tab or the folded corner. See [Appearance and +themes](appearance-and-themes/README.md) for the full set. diff --git a/docs/gallery/appearance-and-themes/README.md b/docs/gallery/appearance-and-themes/README.md new file mode 100644 index 0000000..f4f7cd4 --- /dev/null +++ b/docs/gallery/appearance-and-themes/README.md @@ -0,0 +1,52 @@ +# Appearance and themes + +Not a topology grouping — box shape/keyword/compartments, shape-aware connector anchoring, and theme showcases, +organized pragmatically by appearance rather than by topology, unlike the rest of this gallery. + +[Back to the gallery index](../README.md) + +## Box appearance + +A node's Shape, Keyword, and Compartments properties select the box outline, an italicized keyword line, and labelled +feature sections, all through the plain input graph model — no downstream renderer-specific code required. This is +generic block-diagram notation; SysML is just one modeling language that uses it. + +![A folder container with two boxes carrying a keyword, one also compartmented, joined by an edge](box-appearance.svg) + +A folder container holding two boxes with a keyword line — one also with a labelled compartment — joined by a decorated +edge. + +## Shape-aware connectors + +A box's Shape can make its true outline diverge from its plain bounding rectangle — a folder's tab, a note's folded +corner, a rounded rectangle's corners. The router keeps connectors off those non-connectable regions and projects each +anchor down to the shape's actual drawn outline, so every connector visibly touches the shape it targets. + +![An external node connected into a folder top face, clear of the tab](folder-top-face-anchor.svg) + +An edge approaching a folder container from above: the connector avoids the tab and anchors on the folder's recessed top +edge instead of floating above it. + +![One of each container shape side by side, each holding content](shape-gallery.svg) + +Every Shape value side by side, each with content appropriate to it: rectangle and rounded-rectangle boxes with a +keyword and a compartment, a folder holding a nested child, and a note holding free-form text — every shape reserves +enough space so its content never overlaps the tab or the folded corner. + +## Themes + +One representative diagram rendered with each of the three built-in themes, showing how the theme controls colours, +stroke, and corner style without touching the layout. These are rendered through the raster path to PNG so each carries +a solid theme background. + +![Representative diagram in the light theme](theme-light.png) + +The light theme, suited to on-screen viewing. + +![Representative diagram in the dark theme](theme-dark.png) + +The dark theme, suited to dark-mode viewing. + +![Representative diagram in the print theme](theme-print.png) + +The print theme, optimised for black-and-white output. diff --git a/docs/gallery/box-appearance.svg b/docs/gallery/appearance-and-themes/box-appearance.svg similarity index 100% rename from docs/gallery/box-appearance.svg rename to docs/gallery/appearance-and-themes/box-appearance.svg diff --git a/docs/gallery/folder-top-face-anchor.svg b/docs/gallery/appearance-and-themes/folder-top-face-anchor.svg similarity index 100% rename from docs/gallery/folder-top-face-anchor.svg rename to docs/gallery/appearance-and-themes/folder-top-face-anchor.svg diff --git a/docs/gallery/shape-gallery.svg b/docs/gallery/appearance-and-themes/shape-gallery.svg similarity index 100% rename from docs/gallery/shape-gallery.svg rename to docs/gallery/appearance-and-themes/shape-gallery.svg diff --git a/docs/gallery/theme-dark.png b/docs/gallery/appearance-and-themes/theme-dark.png similarity index 100% rename from docs/gallery/theme-dark.png rename to docs/gallery/appearance-and-themes/theme-dark.png diff --git a/docs/gallery/theme-light.png b/docs/gallery/appearance-and-themes/theme-light.png similarity index 100% rename from docs/gallery/theme-light.png rename to docs/gallery/appearance-and-themes/theme-light.png diff --git a/docs/gallery/theme-print.png b/docs/gallery/appearance-and-themes/theme-print.png similarity index 100% rename from docs/gallery/theme-print.png rename to docs/gallery/appearance-and-themes/theme-print.png diff --git a/docs/gallery/connectivity-and-clusters/README.md b/docs/gallery/connectivity-and-clusters/README.md new file mode 100644 index 0000000..203d9b5 --- /dev/null +++ b/docs/gallery/connectivity-and-clusters/README.md @@ -0,0 +1,64 @@ +# Connectivity and clusters + +Compares a normal, fully-connected graph against graphs with little or no connectivity between nodes — isolated +singletons and/or multiple unrelated components — across the containment, auto, and layered algorithms. + +[Back to the gallery index](../README.md) + +## Layout algorithms + +The bundled algorithms, each laying out the same kind of graph in its own style. Select one with the algorithm option +and let the engine place the boxes and route any edges. + +![Containment packed diagram](containment-packed.svg) + +Sibling boxes packed compactly by the containment algorithm. + +## Baseline: a fully-connected graph, for contrast + +Before the disconnected-graph comparisons in the next section, this baseline shows the same kind of small pipeline fully +connected, laid out directly by the layered algorithm — the normal, connected case the "auto" meta-algorithm examples +below are deliberately contrasted against. + +![A small, fully-connected pipeline laid out by the layered algorithm](layered-regression-baseline.svg) + +A baseline, fully-connected graph laid out directly by the layered algorithm, for visual contrast with the +disconnected-graph diagrams in the next section. + +## The auto meta-algorithm + +The bundled "auto" algorithm splits the input graph into its connected top-level components, routes each component to +whichever bundled leaf algorithm best suits its shape — layered for a connected cluster, hierarchical for any component +holding a container node, containment for the shared bucket of childless, edgeless singletons — lays each piece out +independently, and packs the results into one combined canvas. + +![One connected cluster packed alongside three isolated singleton boxes](auto-cluster-plus-isolated.svg) + +The "auto" algorithm routes the two-node cluster through the layered algorithm and gathers the three unrelated +singletons into one shared bucket routed through the containment algorithm, then packs both pieces into one canvas. + +![Three disconnected two-node clusters, each routed and packed independently](auto-multiple-disconnected-clusters.svg) + +The same three-cluster graph routed through "auto": each cluster is its own connected component, so each is laid out by +the layered algorithm independently and the three results are packed into one combined canvas. + +![The same three clusters laid out directly by the layered algorithm](layered-multiple-disconnected-clusters.svg) + +The companion direct-"layered" sibling of the diagram above: the same disconnected graph, laid out by the layered +algorithm's own internal component packing rather than "auto"'s per-component routing, for comparison. + +![Five entirely isolated boxes packed by the auto algorithm](auto-all-isolated.svg) + +A graph of nothing but childless, edgeless singleton nodes: "auto" gathers every one of them into the shared bucket and +routes the whole graph through the containment algorithm unchanged, taking its zero-copy fast path. + +## Containment packing heuristics + +The containment algorithm derives its row-wrapping content-width budget from the packed boxes' shape, combining an +area-based estimate with a column-count-based one so both a few large boxes and many small ones wrap into a balanced +block. + +![Twelve small, wide boxes packed into a balanced multi-column block](containment-many-small-wide-boxes.svg) + +Twelve identically-sized, wide boxes: the column-count-based content-width candidate keeps the containment algorithm +from packing them into one long, narrow column, wrapping them into a balanced grid of columns instead. diff --git a/docs/gallery/connectivity-and-clusters/auto-all-isolated.svg b/docs/gallery/connectivity-and-clusters/auto-all-isolated.svg new file mode 100644 index 0000000..b6ff399 --- /dev/null +++ b/docs/gallery/connectivity-and-clusters/auto-all-isolated.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + N1 + + N2 + + N3 + + N4 + + N5 + diff --git a/docs/gallery/connectivity-and-clusters/auto-cluster-plus-isolated.svg b/docs/gallery/connectivity-and-clusters/auto-cluster-plus-isolated.svg new file mode 100644 index 0000000..68b8fda --- /dev/null +++ b/docs/gallery/connectivity-and-clusters/auto-cluster-plus-isolated.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A + + B + + + Solo1 + + Solo2 + + Solo3 + diff --git a/docs/gallery/connectivity-and-clusters/auto-multiple-disconnected-clusters.svg b/docs/gallery/connectivity-and-clusters/auto-multiple-disconnected-clusters.svg new file mode 100644 index 0000000..93b9808 --- /dev/null +++ b/docs/gallery/connectivity-and-clusters/auto-multiple-disconnected-clusters.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A1 + + A2 + + + B1 + + B2 + + + C1 + + C2 + + diff --git a/docs/gallery/connectivity-and-clusters/containment-many-small-wide-boxes.svg b/docs/gallery/connectivity-and-clusters/containment-many-small-wide-boxes.svg new file mode 100644 index 0000000..f2a76b3 --- /dev/null +++ b/docs/gallery/connectivity-and-clusters/containment-many-small-wide-boxes.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Box0 + + Box1 + + Box2 + + Box3 + + Box4 + + Box5 + + Box6 + + Box7 + + Box8 + + Box9 + + Box10 + + Box11 + diff --git a/docs/gallery/containment-packed.svg b/docs/gallery/connectivity-and-clusters/containment-packed.svg similarity index 100% rename from docs/gallery/containment-packed.svg rename to docs/gallery/connectivity-and-clusters/containment-packed.svg diff --git a/docs/gallery/connectivity-and-clusters/layered-multiple-disconnected-clusters.svg b/docs/gallery/connectivity-and-clusters/layered-multiple-disconnected-clusters.svg new file mode 100644 index 0000000..bc1a3ec --- /dev/null +++ b/docs/gallery/connectivity-and-clusters/layered-multiple-disconnected-clusters.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A1 + + A2 + + B1 + + B2 + + C1 + + C2 + + + + diff --git a/docs/gallery/connectivity-and-clusters/layered-regression-baseline.svg b/docs/gallery/connectivity-and-clusters/layered-regression-baseline.svg new file mode 100644 index 0000000..8bddf5f --- /dev/null +++ b/docs/gallery/connectivity-and-clusters/layered-regression-baseline.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Start + + Middle + + Branch + + End + + + + diff --git a/docs/gallery/custom-rendering/README.md b/docs/gallery/custom-rendering/README.md new file mode 100644 index 0000000..cf1ebb2 --- /dev/null +++ b/docs/gallery/custom-rendering/README.md @@ -0,0 +1,33 @@ +# Custom rendering + +Every other group in this gallery shows what the layout engine itself produces: describe a graph, call +LayoutEngine.Layout, render the result. This group is the deliberate exception. Its diagrams are hand-built LayoutTrees +with boxes placed at explicit, hardcoded coordinates, rendered by calling ConnectorRouter and the renderers directly — +no layout algorithm is involved at all. This is a legitimate, fully public capability (every type used here is public), +so it earns its own honestly-labelled home rather than being mixed into a showcase of algorithm output. Expect rough +edges: nothing here benefited from any algorithm's spacing or routing intelligence, and a person taking this path on +their own diagrams should expect the same trade-off. + +[Back to the gallery index](../README.md) + +## Direct ConnectorRouter regression coverage + +Regression coverage that requires an exact, hardcoded box arrangement no layout algorithm currently produces, kept as +permanent visual and numeric evidence that a specific connector-routing bug stays fixed regardless of which algorithm +(or future heuristic change) might eventually produce a similar arrangement. + +![Nine edges sharing a narrow inter-box gap](parallel-edges-into-compartment-box-side-by-side.svg) + +Regression coverage for the OrthogonalEdgeRouter narrow-gap tangling fix: a small Source box placed explicitly beside a +taller, nine-row-compartment Target box with a narrow gap between them, joined by nine hand-routed connectors (bypassing +any layout algorithm, so this exact geometry stays covered regardless of which algorithm or future heuristic change +might produce it). Before the fix, each successive parallel connector's own soft-obstacle avoidance offset its candidate +lane a little further from the shared gap with no ceiling, eventually looping a route back behind the Source box it had +already left; the router now clamps soft-obstacle lane candidates to the box pair's own envelope and penalizes (without +blocking) any move that still leaves it. The router also now prices a connector transversally crossing another +already-routed connector's trunk (OrthogonalEdgeRouter's SoftObstacleCrossingCost), so a connector with a genuinely +bounded-cost non-crossing alternative takes it; this gap is saturated enough (nine connectors, one alternate lane) that +one connector's own approach stub still crosses two others' trunks here, because that connector is routed before the two +it later crosses and so cannot see them as obstacles yet — a known single-pass routing-order limitation, not a pricing +gap, left as documented residual behavior rather than force a much wider routing-order change for this one saturated +diagram. diff --git a/docs/gallery/custom-rendering/parallel-edges-into-compartment-box-side-by-side.svg b/docs/gallery/custom-rendering/parallel-edges-into-compartment-box-side-by-side.svg new file mode 100644 index 0000000..ee2e81c --- /dev/null +++ b/docs/gallery/custom-rendering/parallel-edges-into-compartment-box-side-by-side.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Source + + Target + + rows + row1 + row2 + row3 + row4 + row5 + row6 + row7 + row8 + row9 + + + + + + + + + + diff --git a/docs/gallery/flow-pipeline/README.md b/docs/gallery/flow-pipeline/README.md new file mode 100644 index 0000000..b55c41d --- /dev/null +++ b/docs/gallery/flow-pipeline/README.md @@ -0,0 +1,52 @@ +# Flow pipeline + +A single connected component with a directed left-to-right or top-to-bottom flow — what a pipeline or flow diagram looks +like once laid out and routed. + +[Back to the gallery index](../README.md) + +## Layout algorithms + +The bundled algorithms, each laying out the same kind of graph in its own style. Select one with the algorithm option +and let the engine place the boxes and route any edges. + +![Layered pipeline diagram](layered-pipeline.svg) + +A directed pipeline laid out left to right by the layered algorithm. + +## Flow direction + +The same directed graph laid out in two flow directions, selected with the direction option, plus a nested container +overriding its own direction independently of its parent. A rightward flow arranges the layers left-to-right for block +and pipeline diagrams; a downward flow arranges them top-to-bottom for action flows and state machines, swapping each +node's width and height so layer spacing follows node height. + +![Directed flow laid out left to right](direction-right.svg) + +The default rightward direction: layers progress left-to-right. + +![The same directed flow laid out top to bottom](direction-down.svg) + +The downward direction: the same graph's layers progress top-to-bottom. + +![A nested container flowing downward inside an outer rightward flow](mixed-direction-nested.svg) + +A container's own direction override is honored independently of its parent: the outer flow runs left-to-right while the +nested container runs top-to-bottom. + +## Edge routing + +Orthogonal connectors step around the boxes between their endpoints instead of cutting through them. + +![Orthogonal edge routed around an obstacle](orthogonal-obstacle.svg) + +A connector routed orthogonally around an intervening container box. + +## Raster output + +One of the layout-algorithm diagrams above is rendered again here through the SkiaSharp raster path to PNG with the same +dark theme, proving multi-format output. + +![Layered pipeline diagram as PNG](layered-pipeline.png) + +The layered pipeline rendered to a raster PNG image. diff --git a/docs/gallery/direction-down.svg b/docs/gallery/flow-pipeline/direction-down.svg similarity index 100% rename from docs/gallery/direction-down.svg rename to docs/gallery/flow-pipeline/direction-down.svg diff --git a/docs/gallery/direction-right.svg b/docs/gallery/flow-pipeline/direction-right.svg similarity index 100% rename from docs/gallery/direction-right.svg rename to docs/gallery/flow-pipeline/direction-right.svg diff --git a/docs/gallery/layered-pipeline.png b/docs/gallery/flow-pipeline/layered-pipeline.png similarity index 100% rename from docs/gallery/layered-pipeline.png rename to docs/gallery/flow-pipeline/layered-pipeline.png diff --git a/docs/gallery/layered-pipeline.svg b/docs/gallery/flow-pipeline/layered-pipeline.svg similarity index 100% rename from docs/gallery/layered-pipeline.svg rename to docs/gallery/flow-pipeline/layered-pipeline.svg diff --git a/docs/gallery/mixed-direction-nested.svg b/docs/gallery/flow-pipeline/mixed-direction-nested.svg similarity index 100% rename from docs/gallery/mixed-direction-nested.svg rename to docs/gallery/flow-pipeline/mixed-direction-nested.svg diff --git a/docs/gallery/orthogonal-obstacle.svg b/docs/gallery/flow-pipeline/orthogonal-obstacle.svg similarity index 100% rename from docs/gallery/orthogonal-obstacle.svg rename to docs/gallery/flow-pipeline/orthogonal-obstacle.svg diff --git a/docs/gallery/layout-regressions/README.md b/docs/gallery/layout-regressions/README.md new file mode 100644 index 0000000..2d276f5 --- /dev/null +++ b/docs/gallery/layout-regressions/README.md @@ -0,0 +1,18 @@ +# Layout regressions + +Kept as its own group per its organizing principle: bug history, not topology. Small graphs that reproduce and pin down +a specific layout bug once fixed, kept as permanent visual and numeric evidence that it stays fixed. + +[Back to the gallery index](../README.md) + +## Isolated-node layer-gap fix + +Small graphs that reproduce and pin down a specific layout bug once fixed, kept as permanent visual and numeric evidence +that it stays fixed. + +![A hub column with an isolated node beside a routing-corridor dummy](isolated-node-layer-gap.svg) + +Regression coverage for the isolated-node layer-gap fix: a genuinely isolated node (Isolated, zero edges) shares its +layer with a dummy bend point created by an unrelated long edge (LongSource to LongTarget). The crossing minimizer now +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. diff --git a/docs/gallery/isolated-node-layer-gap.svg b/docs/gallery/layout-regressions/isolated-node-layer-gap.svg similarity index 80% rename from docs/gallery/isolated-node-layer-gap.svg rename to docs/gallery/layout-regressions/isolated-node-layer-gap.svg index 5acccfb..09d1a2e 100644 --- a/docs/gallery/isolated-node-layer-gap.svg +++ b/docs/gallery/layout-regressions/isolated-node-layer-gap.svg @@ -1,4 +1,4 @@ - + @@ -32,10 +32,10 @@ Entry - - Isolated - - FarSource + + Isolated + + FarSource HubA @@ -46,17 +46,17 @@ HubD HubE - - Sink + + Sink - - - - - - + + + + + + diff --git a/docs/gallery/nested-hierarchy/README.md b/docs/gallery/nested-hierarchy/README.md new file mode 100644 index 0000000..956f35f --- /dev/null +++ b/docs/gallery/nested-hierarchy/README.md @@ -0,0 +1,65 @@ +# Nested hierarchy + +Parent/child containment and boundary-port delegation into nested children — what a container-with-children graph looks +like once laid out and routed. + +[Back to the gallery index](../README.md) + +## Layout algorithms + +The bundled algorithms, each laying out the same kind of graph in its own style. Select one with the algorithm option +and let the engine place the boxes and route any edges. + +![Hierarchical nested diagram](hierarchical-nested.svg) + +A container node holding a nested child graph, with a cross-container edge. + +## Raster output + +One of the layout-algorithm diagrams above is rendered again here through the SkiaSharp raster path to PNG with the same +dark theme, proving multi-format output. + +![Hierarchical nested diagram as PNG](hierarchical-nested.png) + +The hierarchical nested diagram rendered to a raster PNG image. + +## The auto meta-algorithm + +The bundled "auto" algorithm splits the input graph into its connected top-level components, routes each component to +whichever bundled leaf algorithm best suits its shape — layered for a connected cluster, hierarchical for any component +holding a container node, containment for the shared bucket of childless, edgeless singletons — lays each piece out +independently, and packs the results into one combined canvas. + +![A nested container routed to hierarchical alongside an unrelated isolated box](auto-nested-routes-hierarchical.svg) + +"auto" routes any component containing a container node through the hierarchical algorithm regardless of its size, while +the unrelated isolated sibling is packed alongside it through the shared containment bucket. + +## Boundary and delegation ports + +The hierarchical engine's support for boundary (delegation) ports: a container may expose a named port carrying BOTH an +external and an internal label at one shared physical anchor on its boundary. An external approach edge from a sibling +reaches the anchor from outside, while one or more internal delegation edges relay the connection inward to the +container's nested children. The container and its children are laid out in one combined recursive pass, and every +converging edge — external approach and internal delegation alike — is routed through the orthogonal corridor router +onto that single shared anchor, with the external label reading outward and the internal label reading inward. + +![A sibling joined to a container's boundary port delegating to two children](boundary-ports-showcase-horizontal.svg) + +A rightward-flowing container exposes one boundary port on its left face carrying both a 'command' external label +(reading outward) and a 'dispatch' internal label (reading inward) at the same shared anchor. The external approach edge +from the sibling and both internal delegation edges (internal fan-out to two nested children) are routed orthogonally +onto that one anchor. + +![Two siblings joined to a container's top-face boundary port and one child](boundary-ports-showcase-vertical.svg) + +The companion downward-flowing case: the boundary port anchors on the container's top face, with external fan-out (two +sibling approach edges) both routed orthogonally onto the one shared anchor, which then delegates inward to the single +nested child. + +![A boundary port delegating through a nested boundary port to a leaf](boundary-ports-showcase-deep-chain.svg) + +A three-level delegation chain: a sibling approaches an outer container's boundary port, which delegates inward to a +nested container's own boundary port, which delegates again to the innermost leaf. Both boundary crossings carry an +outward external and an inward internal label, and the whole chain is routed orthogonally in one combined recursive pass +with no diagonal shortcut at either boundary. diff --git a/docs/gallery/nested-hierarchy/auto-nested-routes-hierarchical.svg b/docs/gallery/nested-hierarchy/auto-nested-routes-hierarchical.svg new file mode 100644 index 0000000..cf47ae9 --- /dev/null +++ b/docs/gallery/nested-hierarchy/auto-nested-routes-hierarchical.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Group + + Inner1 + + Inner2 + + + Solo + diff --git a/docs/gallery/boundary-ports-showcase-deep-chain.png b/docs/gallery/nested-hierarchy/boundary-ports-showcase-deep-chain.png similarity index 100% rename from docs/gallery/boundary-ports-showcase-deep-chain.png rename to docs/gallery/nested-hierarchy/boundary-ports-showcase-deep-chain.png diff --git a/docs/gallery/boundary-ports-showcase-deep-chain.svg b/docs/gallery/nested-hierarchy/boundary-ports-showcase-deep-chain.svg similarity index 100% rename from docs/gallery/boundary-ports-showcase-deep-chain.svg rename to docs/gallery/nested-hierarchy/boundary-ports-showcase-deep-chain.svg diff --git a/docs/gallery/boundary-ports-showcase-horizontal.png b/docs/gallery/nested-hierarchy/boundary-ports-showcase-horizontal.png similarity index 100% rename from docs/gallery/boundary-ports-showcase-horizontal.png rename to docs/gallery/nested-hierarchy/boundary-ports-showcase-horizontal.png diff --git a/docs/gallery/boundary-ports-showcase-horizontal.svg b/docs/gallery/nested-hierarchy/boundary-ports-showcase-horizontal.svg similarity index 100% rename from docs/gallery/boundary-ports-showcase-horizontal.svg rename to docs/gallery/nested-hierarchy/boundary-ports-showcase-horizontal.svg diff --git a/docs/gallery/boundary-ports-showcase-vertical.png b/docs/gallery/nested-hierarchy/boundary-ports-showcase-vertical.png similarity index 100% rename from docs/gallery/boundary-ports-showcase-vertical.png rename to docs/gallery/nested-hierarchy/boundary-ports-showcase-vertical.png diff --git a/docs/gallery/boundary-ports-showcase-vertical.svg b/docs/gallery/nested-hierarchy/boundary-ports-showcase-vertical.svg similarity index 100% rename from docs/gallery/boundary-ports-showcase-vertical.svg rename to docs/gallery/nested-hierarchy/boundary-ports-showcase-vertical.svg diff --git a/docs/gallery/hierarchical-nested.png b/docs/gallery/nested-hierarchy/hierarchical-nested.png similarity index 100% rename from docs/gallery/hierarchical-nested.png rename to docs/gallery/nested-hierarchy/hierarchical-nested.png diff --git a/docs/gallery/hierarchical-nested.svg b/docs/gallery/nested-hierarchy/hierarchical-nested.svg similarity index 100% rename from docs/gallery/hierarchical-nested.svg rename to docs/gallery/nested-hierarchy/hierarchical-nested.svg diff --git a/docs/gallery/parallel-edges-and-ports/README.md b/docs/gallery/parallel-edges-and-ports/README.md new file mode 100644 index 0000000..add9e77 --- /dev/null +++ b/docs/gallery/parallel-edges-and-ports/README.md @@ -0,0 +1,87 @@ +# Parallel edges and ports + +Dense parallel connections between a small number of boxes, including named/boundary ports and edge-count-aware gap +widening — the same small-box-count-many-edges shape compared across algorithms. + +[Back to the gallery index](../README.md) + +## Parallel-edge routing regressions + +Regression coverage specific to routing many parallel connectors around and between boxes: a small graph, laid out by +the containment algorithm, that reproduces a specific connector-routing bug once fixed, kept as permanent visual and +numeric evidence that it stays fixed. + +![Nine edges docking on a compartment box without crossing its interior](parallel-edges-into-compartment-box.svg) + +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. + +## Parallel edges and named ports + +The layered algorithm's Phase 1 flat-graph support for multiple parallel connectors between the same two boxes, and for +named ports attached to a specific, labelled location on a node's boundary. Parallel edges either collapse to one +rendered connector (the default) or each keep their own independently-routed line, selected with the MergeParallelEdges +option; each node's ContentInset margins are auto-computed from its ports' measured label widths so port text never +overlaps the box's own content. + +![Three parallel connectors between the same two boxes, independently routed](parallel-edges-preserved.svg) + +MergeParallelEdges set to false: all three parallel connectors survive, each with its own label. + +![The same three parallel connectors on a downward-flowing pair of boxes](parallel-edges-preserved-vertical.svg) + +The companion vertical-flow case: with a downward Direction the three parallel connectors anchor on the boxes' top and +bottom faces instead of their left and right faces, and each box's WIDTH (not height) auto-grows to fit the widened lane +spacing, since PortDistributor spreads anchors on a top/bottom face horizontally. + +![The same three parallel connectors collapsed to a single line](parallel-edges-merged.svg) + +The default MergeParallelEdges (true): the three parallel connectors collapse to a single rendered line, and its +midpoint label is omitted entirely (not any single surviving connector's label) since a reader could not tell which of +the three collapsed connectors a kept label would have belonged to. + +![A hub node with a named port on each of its left and right sides](ports-showcase-horizontal.svg) + +Left/right named ports on a rightward-flowing hub node; the long left-side incoming label auto-computes a widened +ContentInsetLeft margin, measured with the Skia-backed text measurer. + +![A hub node with a named port on each of its top and bottom sides](ports-showcase-vertical.svg) + +The companion top/bottom case: a downward-flowing hub node, whose ports anchor on its top and bottom faces instead. + +![A hub node with two named ports on each of its left and right sides](ports-showcase-multi-connector-horizontal.svg) + +Same-face crowding with two independently-labelled ports per side (one deliberately long): PortDistributor spreads both +anchors on each face without collapsing them onto one row, and the hub's title stays clear of both stacked rows on +either side. + +![A hub node with two named ports on each of its top and bottom sides](ports-showcase-multi-connector-vertical.svg) + +The companion top/bottom case: two ports per face spread horizontally instead of vertically, proving the same crowding +and title-collision protection when PortDistributor works along the cross axis of a downward flow. + +![A titled hub node with several unlabeled ports fanning out to four other boxes](ports-showcase-unlabeled-fan-out.svg) + +A face with several ports carrying no label or text at all still grows the box tall enough to keep them from bunching +together — the growth floor applies unconditionally to any face with two or more anchors, not only labeled ones. + +## Edge-count gap widening + +When many parallel connectors fan through the single gap between two side-by-side boxes, that gap is widened in +proportion to the connector count so each connector gets its own orthogonal routing lane instead of being crushed into +one narrow channel — the same corridor-width reservation the layered pipeline already makes between its columns, now +applied to the containment packer's same-row pairs and to the hierarchical engine's side-by-side sibling containers. + +![Eight edges fanning through a widened containment gap](containment-parallel-edges-side-by-side.svg) + +The containment algorithm packs two tall, compartment-bearing peer boxes side by side and widens the horizontal gap +between them in proportion to the eight parallel connectors routed through it, so the connectors fan cleanly into +distinct lanes rather than crowding a fixed-width channel. + +![Eight cross-container edges through a widened sibling gap](hierarchical-parallel-edges-side-by-side.svg) + +The hierarchical engine places two peer containers side by side and widens the gap between them for the eight +cross-container connectors that fan child-to-child through it — spacing the per-scope leaf algorithm alone cannot +reserve, because those edges never appear in the sized view it lays out. diff --git a/docs/gallery/parallel-edges-and-ports/containment-parallel-edges-side-by-side.svg b/docs/gallery/parallel-edges-and-ports/containment-parallel-edges-side-by-side.svg new file mode 100644 index 0000000..6b5bc3e --- /dev/null +++ b/docs/gallery/parallel-edges-and-ports/containment-parallel-edges-side-by-side.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Left + + rows + row1 + row2 + row3 + row4 + row5 + row6 + row7 + row8 + row9 + + Right + + rows + row1 + row2 + row3 + row4 + row5 + row6 + row7 + row8 + row9 + + + + + + + + + diff --git a/docs/gallery/parallel-edges-and-ports/hierarchical-parallel-edges-side-by-side.svg b/docs/gallery/parallel-edges-and-ports/hierarchical-parallel-edges-side-by-side.svg new file mode 100644 index 0000000..0a0d897 --- /dev/null +++ b/docs/gallery/parallel-edges-and-ports/hierarchical-parallel-edges-side-by-side.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Left + + LeftInner + + rows + row1 + row2 + row3 + row4 + row5 + row6 + row7 + row8 + row9 + + Right + + RightInner + + rows + row1 + row2 + row3 + row4 + row5 + row6 + row7 + row8 + row9 + + + + + + + + + diff --git a/docs/gallery/parallel-edges-into-compartment-box.svg b/docs/gallery/parallel-edges-and-ports/parallel-edges-into-compartment-box.svg similarity index 100% rename from docs/gallery/parallel-edges-into-compartment-box.svg rename to docs/gallery/parallel-edges-and-ports/parallel-edges-into-compartment-box.svg diff --git a/docs/gallery/parallel-edges-merged.png b/docs/gallery/parallel-edges-and-ports/parallel-edges-merged.png similarity index 100% rename from docs/gallery/parallel-edges-merged.png rename to docs/gallery/parallel-edges-and-ports/parallel-edges-merged.png diff --git a/docs/gallery/parallel-edges-merged.svg b/docs/gallery/parallel-edges-and-ports/parallel-edges-merged.svg similarity index 100% rename from docs/gallery/parallel-edges-merged.svg rename to docs/gallery/parallel-edges-and-ports/parallel-edges-merged.svg diff --git a/docs/gallery/parallel-edges-preserved-vertical.png b/docs/gallery/parallel-edges-and-ports/parallel-edges-preserved-vertical.png similarity index 100% rename from docs/gallery/parallel-edges-preserved-vertical.png rename to docs/gallery/parallel-edges-and-ports/parallel-edges-preserved-vertical.png diff --git a/docs/gallery/parallel-edges-preserved-vertical.svg b/docs/gallery/parallel-edges-and-ports/parallel-edges-preserved-vertical.svg similarity index 100% rename from docs/gallery/parallel-edges-preserved-vertical.svg rename to docs/gallery/parallel-edges-and-ports/parallel-edges-preserved-vertical.svg diff --git a/docs/gallery/parallel-edges-preserved.png b/docs/gallery/parallel-edges-and-ports/parallel-edges-preserved.png similarity index 100% rename from docs/gallery/parallel-edges-preserved.png rename to docs/gallery/parallel-edges-and-ports/parallel-edges-preserved.png diff --git a/docs/gallery/parallel-edges-preserved.svg b/docs/gallery/parallel-edges-and-ports/parallel-edges-preserved.svg similarity index 100% rename from docs/gallery/parallel-edges-preserved.svg rename to docs/gallery/parallel-edges-and-ports/parallel-edges-preserved.svg diff --git a/docs/gallery/ports-showcase-horizontal.png b/docs/gallery/parallel-edges-and-ports/ports-showcase-horizontal.png similarity index 100% rename from docs/gallery/ports-showcase-horizontal.png rename to docs/gallery/parallel-edges-and-ports/ports-showcase-horizontal.png diff --git a/docs/gallery/ports-showcase-horizontal.svg b/docs/gallery/parallel-edges-and-ports/ports-showcase-horizontal.svg similarity index 100% rename from docs/gallery/ports-showcase-horizontal.svg rename to docs/gallery/parallel-edges-and-ports/ports-showcase-horizontal.svg diff --git a/docs/gallery/ports-showcase-multi-connector-horizontal.png b/docs/gallery/parallel-edges-and-ports/ports-showcase-multi-connector-horizontal.png similarity index 100% rename from docs/gallery/ports-showcase-multi-connector-horizontal.png rename to docs/gallery/parallel-edges-and-ports/ports-showcase-multi-connector-horizontal.png diff --git a/docs/gallery/ports-showcase-multi-connector-horizontal.svg b/docs/gallery/parallel-edges-and-ports/ports-showcase-multi-connector-horizontal.svg similarity index 100% rename from docs/gallery/ports-showcase-multi-connector-horizontal.svg rename to docs/gallery/parallel-edges-and-ports/ports-showcase-multi-connector-horizontal.svg diff --git a/docs/gallery/ports-showcase-multi-connector-vertical.png b/docs/gallery/parallel-edges-and-ports/ports-showcase-multi-connector-vertical.png similarity index 100% rename from docs/gallery/ports-showcase-multi-connector-vertical.png rename to docs/gallery/parallel-edges-and-ports/ports-showcase-multi-connector-vertical.png diff --git a/docs/gallery/ports-showcase-multi-connector-vertical.svg b/docs/gallery/parallel-edges-and-ports/ports-showcase-multi-connector-vertical.svg similarity index 100% rename from docs/gallery/ports-showcase-multi-connector-vertical.svg rename to docs/gallery/parallel-edges-and-ports/ports-showcase-multi-connector-vertical.svg diff --git a/docs/gallery/ports-showcase-unlabeled-fan-out.png b/docs/gallery/parallel-edges-and-ports/ports-showcase-unlabeled-fan-out.png similarity index 100% rename from docs/gallery/ports-showcase-unlabeled-fan-out.png rename to docs/gallery/parallel-edges-and-ports/ports-showcase-unlabeled-fan-out.png diff --git a/docs/gallery/ports-showcase-unlabeled-fan-out.svg b/docs/gallery/parallel-edges-and-ports/ports-showcase-unlabeled-fan-out.svg similarity index 100% rename from docs/gallery/ports-showcase-unlabeled-fan-out.svg rename to docs/gallery/parallel-edges-and-ports/ports-showcase-unlabeled-fan-out.svg diff --git a/docs/gallery/ports-showcase-vertical.png b/docs/gallery/parallel-edges-and-ports/ports-showcase-vertical.png similarity index 100% rename from docs/gallery/ports-showcase-vertical.png rename to docs/gallery/parallel-edges-and-ports/ports-showcase-vertical.png diff --git a/docs/gallery/ports-showcase-vertical.svg b/docs/gallery/parallel-edges-and-ports/ports-showcase-vertical.svg similarity index 100% rename from docs/gallery/ports-showcase-vertical.svg rename to docs/gallery/parallel-edges-and-ports/ports-showcase-vertical.svg diff --git a/docs/reqstream/ots/sonarscanner.yaml b/docs/reqstream/ots/sonarscanner.yaml new file mode 100644 index 0000000..37eb552 --- /dev/null +++ b/docs/reqstream/ots/sonarscanner.yaml @@ -0,0 +1,21 @@ +--- +# SonarScanner for .NET OTS Software Requirements +# +# Requirements for the SonarScanner for .NET static-analysis scanner functionality. + +sections: + - title: OTS Software Requirements + sections: + - title: SonarScanner for .NET Requirements + requirements: + - id: Rendering-OTS-SonarScanner + title: SonarScanner for .NET shall analyze the build and publish results to SonarCloud. + justification: | + The dotnet-sonarscanner local tool wraps the build with begin/end analysis steps that + upload source metrics, coverage, and issues to SonarCloud. It runs in the same CI + pipeline that produces the TRX test results consumed by SonarMark, so a successful + pipeline run is evidence that SonarScanner for .NET executed without error. + tags: [ots] + tests: + - SonarScanner_BeginAnalysis + - SonarScanner_EndAnalysisPublishesResults diff --git a/docs/reqstream/rendering-layout.yaml b/docs/reqstream/rendering-layout.yaml index 5d7b6e4..17e68cd 100644 --- a/docs/reqstream/rendering-layout.yaml +++ b/docs/reqstream/rendering-layout.yaml @@ -5,9 +5,10 @@ # - Define requirements for the Rendering.Layout software system # (package DemaConsulting.Rendering.Layout). # - The system provides the bundled layout algorithms - the ELK-style layered algorithm, the -# containment (shelf-packing) algorithm, and the recursive hierarchical engine that composes them - -# plus public connector routing, the EdgeRouting option realization, a default layout facade, and -# the reusable geometric engines the algorithms are built from. +# containment (shelf-packing) algorithm, the recursive hierarchical engine, and the auto-routing +# meta-algorithm that composes them per connected component - plus public connector routing, the +# EdgeRouting option realization, a default layout facade, and the reusable geometric engines the +# algorithms are built from. # - Requirements describe observable geometric behavior, not the algorithms used. # # STRUCTURE: @@ -202,5 +203,32 @@ sections: - Rendering-Layout-LayoutEngine-CustomRegistry - Rendering-Layout-LayoutEngine-Validation tests: - - CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms + - CreateDefaultRegistry_RegistersOnlyTheFourBundledAlgorithms - Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly + + - id: Rendering-Layout-AutoAlgorithm + title: >- + The rendering layout system shall provide a named auto-routing meta-algorithm that splits a + layout graph into its connected top-level components, routes each component to whichever + bundled leaf algorithm best suits its shape, lays each component out independently, and packs + the results into one combined placed layout tree. + justification: | + A caller with a graph of unknown or mixed shape — some connected clusters, some nested + containers, some unrelated singleton nodes — should not have to inspect the graph and choose + an algorithm by hand for each piece. Exposing a fourth bundled algorithm that routes each + connected component by shape and composes the independently-placed results lets a caller + select "auto" once and get a reasonable layout for any mixture, additively, without changing + any of the three existing bundled algorithms or their output. + children: + - Rendering-Layout-AutoAlgorithm-Identity + - Rendering-Layout-AutoAlgorithm-RoutesConnectedCluster + - Rendering-Layout-AutoAlgorithm-RoutesContainer + - Rendering-Layout-AutoAlgorithm-RoutesSingletonBucket + - Rendering-Layout-AutoAlgorithm-FastPathEquivalence + - Rendering-Layout-AutoAlgorithm-MultiGroupPacking + - Rendering-Layout-AutoAlgorithm-CascadesOptions + - Rendering-Layout-AutoAlgorithm-EmptyGraph + - Rendering-Layout-AutoAlgorithm-Validation + tests: + - Id_IsAuto + - Apply_ClusterPlusIsolatedSingletons_PacksBothGroupsWithoutOverlap diff --git a/docs/reqstream/rendering-layout/auto-layout-algorithm.yaml b/docs/reqstream/rendering-layout/auto-layout-algorithm.yaml new file mode 100644 index 0000000..62b7775 --- /dev/null +++ b/docs/reqstream/rendering-layout/auto-layout-algorithm.yaml @@ -0,0 +1,123 @@ +--- +# DemaConsulting.Rendering.Layout - AutoLayoutAlgorithm Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the auto-layout-algorithm unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: AutoLayoutAlgorithm Requirements + requirements: + - id: Rendering-Layout-AutoAlgorithm-Identity + title: >- + AutoLayoutAlgorithm shall expose the stable identifier under which it is selected and + registered. + justification: | + Renderers and registries select an algorithm by its identifier; a stable, well-known value + lets the auto meta-algorithm be resolved by name. + tests: + - Id_IsAuto + + - id: Rendering-Layout-AutoAlgorithm-RoutesConnectedCluster + title: >- + AutoLayoutAlgorithm shall route a connected component of two or more nodes with no children + anywhere in it (including a single node with only a self-loop) through the layered algorithm. + justification: | + A component with genuine connectivity and no nesting is exactly the shape the layered + algorithm's Sugiyama layering is built for, so routing it there produces the same quality + layout a caller would get by selecting layered directly. + tests: + - Apply_SingleConnectedComponent_MatchesLayeredAlgorithmDirectly + - Apply_SingleNodeWithSelfLoop_RoutesToLayeredNotContainment + - Apply_MultipleDisconnectedClusters_PacksEachClusterWithoutOverlap + + - id: Rendering-Layout-AutoAlgorithm-RoutesContainer + title: >- + AutoLayoutAlgorithm shall route any component containing a node with children through the + hierarchical algorithm, regardless of the component's overall size or connectivity. + justification: | + A container node needs the hierarchical engine's recursive placement to lay out its own + children even when the container itself is otherwise isolated or unconnected; routing by + nesting rather than by connectivity alone ensures nested content is never flattened. + tests: + - Apply_IsolatedNodeWithChildren_MatchesHierarchicalAlgorithmDirectly + - Apply_NestedContainers_RoutesToHierarchical + + - id: Rendering-Layout-AutoAlgorithm-RoutesSingletonBucket + title: >- + AutoLayoutAlgorithm shall gather every truly childless, edgeless singleton node in the graph + into one shared bucket routed through the containment algorithm. + justification: | + A singleton with no children and no incident edges carries no connectivity or nesting + information a layered or hierarchical layout could use; packing every such node together + through the containment algorithm's balanced-block packer is the only routing that produces a + meaningful arrangement for it. + tests: + - Apply_AllIsolatedSingletons_MatchesContainmentAlgorithmDirectly + - Apply_ClusterPlusIsolatedSingletons_PacksBothGroupsWithoutOverlap + + - id: Rendering-Layout-AutoAlgorithm-FastPathEquivalence + title: >- + When the routing rule produces exactly one group overall, AutoLayoutAlgorithm shall delegate + the unmodified graph directly to that group's leaf algorithm and produce a result + byte-for-byte identical to invoking that algorithm directly. + justification: | + The common case is a single, fully (or mostly) connected diagram; that case must never pay any + splitting, copying, or repacking cost, and must never differ from what a caller would get by + selecting the equivalent leaf algorithm directly. + tests: + - Apply_SingleConnectedComponent_MatchesLayeredAlgorithmDirectly + - Apply_AllIsolatedSingletons_MatchesContainmentAlgorithmDirectly + - Apply_IsolatedNodeWithChildren_MatchesHierarchicalAlgorithmDirectly + - Apply_NestedContainers_RoutesToHierarchical + - Apply_SingleNodeWithSelfLoop_RoutesToLayeredNotContainment + + - id: Rendering-Layout-AutoAlgorithm-MultiGroupPacking + title: >- + When the routing rule produces 2+ groups, AutoLayoutAlgorithm shall lay out each group + independently on its own copied sub-graph and pack every resulting placed tree into one + combined layout tree with no two groups' bounding boxes overlapping. + justification: | + A graph mixing multiple independent clusters, containers, and singletons must still produce + one coherent, non-overlapping combined diagram rather than requiring the caller to lay out and + compose each piece by hand. + tests: + - Apply_ClusterPlusIsolatedSingletons_PacksBothGroupsWithoutOverlap + - Apply_MultipleDisconnectedClusters_PacksEachClusterWithoutOverlap + + - id: Rendering-Layout-AutoAlgorithm-CascadesOptions + title: >- + AutoLayoutAlgorithm shall capture the graph's own cascaded options once before splitting and + apply them as the fallback options to every split-off group, and shall reset the captured + algorithm-selection option so a hierarchical-routed group's own nested cascade never attempts + to resolve the meta-algorithm's own identifier from a leaf-only registry. + justification: | + A graph-level option override (direction, spacing, edge routing, and so on) must still apply + to every split-off piece exactly as it would have applied to the whole graph. Without + resetting the captured algorithm-selection option, a graph that declares "auto" explicitly and + also splits into a hierarchical-routed group throws a lookup error instead of producing a + layout, since the hierarchical algorithm's own leaf-only registry never contains "auto". + tests: + - Apply_GraphLevelOptionOverride_AppliesToSplitComponents + - Apply_GraphDeclaresAutoAlgorithmWithNestedContainerGroup_DoesNotThrow + + - id: Rendering-Layout-AutoAlgorithm-EmptyGraph + title: >- + When given a graph with no nodes, AutoLayoutAlgorithm shall return an empty placed layout + tree. + justification: | + An empty graph is a valid degenerate case that must yield a well-formed empty canvas rather + than an error. + tests: + - Apply_EmptyGraph_ReturnsEmptyTree + + - id: Rendering-Layout-AutoAlgorithm-Validation + title: >- + AutoLayoutAlgorithm shall reject a null graph or null options argument with an argument-null + error. + justification: | + Failing fast on null arguments prevents undefined behavior and surfaces caller errors at the + public boundary. + tests: + - Apply_NullGraph_ThrowsArgumentNullException + - ApplyCore_NullOptions_ThrowsArgumentNullException diff --git a/docs/reqstream/rendering-layout/containment-layout-algorithm.yaml b/docs/reqstream/rendering-layout/containment-layout-algorithm.yaml index 08ba142..9c668a4 100644 --- a/docs/reqstream/rendering-layout/containment-layout-algorithm.yaml +++ b/docs/reqstream/rendering-layout/containment-layout-algorithm.yaml @@ -90,3 +90,44 @@ sections: LayeredLayoutAlgorithm's graph-then-options-then-default resolution of CoreOptions.Direction. tests: - Apply_EdgeRoutingOverrideOnGraphScope_IsHonored + + - id: Rendering-Layout-ContainmentAlgorithm-EdgeCountGapWidening + title: >- + ContainmentLayoutAlgorithm shall count the edges between each unordered pair of top-level nodes + and, for two such nodes packed side by side on the same row, widen the horizontal gap between + them to the connector-corridor width for that edge count, so a fan of parallel connectors gets + distinct routing lanes. + justification: | + Two peer boxes joined by several parallel edges otherwise pack against a fixed node spacing that + crushes the connectors into one channel. Reusing the shared corridor-width formula + (EdgeCountGapWidener) — the same reservation the layered pipeline already makes between its + columns — sizes the gap to the edge count. + tests: + - Apply_SameRowPeersWithParallelEdges_WidensGapPastNodeSpacing + + - id: Rendering-Layout-ContainmentAlgorithm-VerticalStackUnaffected + title: >- + The edge-count gap widening shall apply to the horizontal (same-row) gap only and shall leave + a vertical stack's box positions byte-identical. + justification: | + The widening is deliberately horizontal-only so a Source-over-Target vertical stack keeps its + exact prior geometry; a parallel-edge fan between vertically stacked peers must not perturb + their positions the way a same-row fan perturbs the horizontal gap. + tests: + - Apply_VerticalStackWithParallelEdges_LeavesBoxPositionsUnchanged + + - id: Rendering-Layout-ContainmentAlgorithm-CorridorWidthFormula + title: >- + The shared EdgeCountGapWidener helper shall widen a base gap to a clearance on each side of the + fan plus one inter-connector spacing per gap between adjacent connectors, never narrowing below + the supplied base gap and never widening for an edge count of zero or one. + justification: | + Extracting the corridor-width formula into one helper keeps the containment packer and the + hierarchical sibling pass byte-identical to each other and to the layered pipeline's own + corridor sizing, preventing copy-paste drift between the three call sites. + tests: + - Widen_ManyConnectors_ReturnsCorridorWidth + - Widen_TwoConnectors_ReturnsSingleSlotCorridor + - Widen_SingleConnector_ReturnsBaseGap + - Widen_ZeroConnectors_ReturnsBaseGap + - Widen_BaseGapExceedsCorridor_ReturnsBaseGap diff --git a/docs/reqstream/rendering-layout/containment-layout.yaml b/docs/reqstream/rendering-layout/containment-layout.yaml index e035fae..134ec14 100644 --- a/docs/reqstream/rendering-layout/containment-layout.yaml +++ b/docs/reqstream/rendering-layout/containment-layout.yaml @@ -89,6 +89,42 @@ sections: tests: - ContainmentOptions_Defaults_AreSensibleGapsAndPadding + - id: Rendering-Layout-ContainmentLayout-EdgeCountGapWidening + title: >- + ContainmentLayout shall widen the horizontal gap between two adjacent same-row child boxes to + the connector-corridor width for the edge count supplied for that pair through the optional + EdgeCounts lookup. + justification: | + A fan of parallel connectors routed through a fixed-width inter-box gap is crushed into one + narrow channel. Widening only the horizontal gap of the same-row pair the connectors fan + through — sizing it from the edge count with the same corridor formula the layered pipeline + uses between its columns — gives each connector a distinct lane. + tests: + - Pack_EdgeCounts_WidensIndicatedRowGap + - Pack_SameRowEdgeCount_WidensHorizontalGap + + - id: Rendering-Layout-ContainmentLayout-EdgeCountGapWideningOptedOut + title: >- + ContainmentLayout shall leave placement byte-identical to the prior behaviour when no + EdgeCounts lookup is supplied (or a null lookup is supplied). + justification: | + The EdgeCounts lookup is an optional, additive parameter; existing callers that predate it, or + callers that explicitly pass a null lookup, must see unchanged geometry. + tests: + - Pack_WithoutEdgeCounts_UsesDefaultGap + - Pack_NullEdgeCounts_ByteIdenticalToNoCounts + + - id: Rendering-Layout-ContainmentLayout-EdgeCountGapWideningRowScoped + title: >- + ContainmentLayout shall never widen a gap for a node pair the wrap decision splits across two + rows, regardless of the edge count supplied for that pair. + justification: | + The corridor-widening reservation only makes sense for a horizontal (same-row) gap; a pair + wrapped onto different rows has no shared horizontal gap to widen, and the wrap decision itself + must keep using the un-widened gap so existing wrap points stay unchanged. + tests: + - Pack_DifferentRowPair_Unaffected + - id: Rendering-Layout-ContainmentLayout-Validation title: >- ContainmentLayout shall reject a null children list, a null options argument, or a null child diff --git a/docs/reqstream/rendering-layout/default-layout.yaml b/docs/reqstream/rendering-layout/default-layout.yaml index da0cbed..e1b0383 100644 --- a/docs/reqstream/rendering-layout/default-layout.yaml +++ b/docs/reqstream/rendering-layout/default-layout.yaml @@ -11,8 +11,8 @@ sections: - id: Rendering-Layout-DefaultRegistry-BundledAlgorithms title: >- The default layout-algorithm registry factory shall return a registry from which each of the - three bundled algorithms — layered, containment, and hierarchical — resolves by its identifier, - and each call shall return an independently mutable registry. + four bundled algorithms — layered, containment, hierarchical, and auto — resolves by its + identifier, and each call shall return an independently mutable registry. justification: | A batteries-included factory removes the boilerplate of registering the bundled algorithms by hand; returning a fresh, independent registry on every call lets a caller extend or replace an @@ -21,7 +21,8 @@ sections: - CreateDefaultRegistry_ResolvesLayeredAlgorithm - CreateDefaultRegistry_ResolvesContainmentAlgorithm - CreateDefaultRegistry_ResolvesHierarchicalAlgorithm - - CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms + - CreateDefaultRegistry_ResolvesAutoAlgorithm + - CreateDefaultRegistry_RegistersOnlyTheFourBundledAlgorithms - CreateDefaultRegistry_ReturnsIndependentInstances - id: Rendering-Layout-LayoutEngine-DefaultAlgorithm diff --git a/docs/reqstream/rendering-layout/engine/containment-packer.yaml b/docs/reqstream/rendering-layout/engine/containment-packer.yaml index 4d32b00..512bbeb 100644 --- a/docs/reqstream/rendering-layout/engine/containment-packer.yaml +++ b/docs/reqstream/rendering-layout/engine/containment-packer.yaml @@ -77,3 +77,13 @@ sections: exactly plus the requested padding on each side. tests: - Pack_SingleItem_PositionsAtPaddingOrigin + + - id: Rendering-Layout-ContainmentPacker-NullItems + title: >- + ContainmentPacker shall throw ArgumentNullException when the items argument is null. + justification: | + A null items list has no items to pack and no way to compute a meaningful region. The + packer must fail loudly with a standard argument-validation exception rather than throwing + a less specific NullReferenceException partway through the algorithm. + tests: + - Pack_NullItems_ThrowsArgumentNullException diff --git a/docs/reqstream/rendering-layout/engine/edge-count-gap-widener.yaml b/docs/reqstream/rendering-layout/engine/edge-count-gap-widener.yaml new file mode 100644 index 0000000..c825039 --- /dev/null +++ b/docs/reqstream/rendering-layout/engine/edge-count-gap-widener.yaml @@ -0,0 +1,46 @@ +--- +# DemaConsulting.Rendering.Layout - EdgeCountGapWidener Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the edge-count-gap-widener unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: EdgeCountGapWidener Requirements + requirements: + - id: Rendering-Layout-EdgeCountGapWidener-CorridorFormula + title: >- + EdgeCountGapWidener shall widen a base gap to at least a clearance on each side plus one + inter-connector spacing per gap between adjacent connectors, for a fan of two or more + parallel connectors. + justification: | + Both the containment packer and the hierarchical algorithm's sibling-widening pass need the + exact same corridor-width math the layered pipeline's BrandesKopfPlacer already uses between + adjacent columns, so a fan of parallel connectors routed through a shared gap always gets + distinct orthogonal lanes regardless of which call site widened the gap. + tests: + - Widen_ManyConnectors_ReturnsCorridorWidth + - Widen_TwoConnectors_ReturnsSingleSlotCorridor + + - id: Rendering-Layout-EdgeCountGapWidener-DegenerateCases + title: >- + EdgeCountGapWidener shall return the base gap unchanged for an edge count of zero or one, + never narrowing it below the supplied value. + justification: | + A single connector (or none) needs no extra lane; narrowing the gap below the caller's + supplied base value would shrink spacing a caller already relies on, so every low-count + caller must keep its pre-existing spacing exactly. + tests: + - Widen_SingleConnector_ReturnsBaseGap + - Widen_ZeroConnectors_ReturnsBaseGap + + - id: Rendering-Layout-EdgeCountGapWidener-NeverNarrows + title: >- + EdgeCountGapWidener shall never return a value smaller than the supplied base gap, even + when the computed corridor width is less than the base gap. + justification: | + The widener only ever adds room for a fan of connectors; a base gap already wide enough for + the corridor must be preserved so the helper cannot silently shrink spacing a caller has + already established for an unrelated reason. + tests: + - Widen_BaseGapExceedsCorridor_ReturnsBaseGap diff --git a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml index 7666331..ce8b8fd 100644 --- a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml +++ b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml @@ -334,6 +334,38 @@ sections: tests: - ComponentPacker_Apply_MultiComponent_PropagatesBackEdgeEntryApproach + - id: Rendering-Layout-LayeredPipeline-ComponentPackingWiredIntoPlace + title: >- + InterconnectionLayoutEngine.Place (and therefore the public layered algorithm) shall + automatically route every input through the component-packing stage, so a disconnected + graph is split into its connected components, each laid out independently, and packed + without overlap, with no caller action required to opt in. + justification: | + The component-packing stage previously existed only as an optional composite stage a caller + had to add explicitly; leaving it unused for the public entry point meant every disconnected + graph laid out through the layered algorithm was still stacked into a single column instead + of packed compactly. Wiring it into Place makes disconnected-graph packing the default, + observable behavior of the layered algorithm itself, not an opt-in feature only reachable by + calling the pipeline stage directly. + tests: + - Place_DisconnectedComponents_PacksEachComponentSeparately + + - id: Rendering-Layout-LayeredPipeline-ComponentPackerIdempotentNormalization + title: >- + AxisTransform.NormalizeInputAxes shall be idempotent so composing the component-packing stage + as an inner stage of a LayeredLayoutPipeline that already normalized the same graph does not + double-swap (and thereby undo) the Down/Up input node axis normalization. + justification: | + The component-packing stage calls AxisTransform.NormalizeInputAxes itself so it remains + self-contained regardless of call site, but LayeredLayoutPipeline.Run already normalizes axes + before its own stage sequence. If the component-packing stage were ever composed as an inner + stage of a pipeline (its ILayoutStage contract and documentation suggest this is supported), + an unguarded second swap would revert a Down/Up graph's node axes to their un-normalized + state, corrupting the packed layout. A per-graph idempotency guard makes both call paths safe + to compose together. + tests: + - ComponentPacker_Apply_ComposedInDownPipeline_DoesNotDoubleSwapAxes + - id: Rendering-Layout-LayeredPipeline-SharedState title: >- The layered graph shall carry the mutable shared state threaded through every stage and diff --git a/docs/reqstream/rendering-layout/engine/layout-tree-packer.yaml b/docs/reqstream/rendering-layout/engine/layout-tree-packer.yaml new file mode 100644 index 0000000..cf6fe7e --- /dev/null +++ b/docs/reqstream/rendering-layout/engine/layout-tree-packer.yaml @@ -0,0 +1,98 @@ +--- +# DemaConsulting.Rendering.Layout - LayoutTreePacker Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the layout-tree-packer unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: LayoutTreePacker Requirements + requirements: + - id: Rendering-Layout-LayoutTreePacker-PacksMultipleTrees + title: >- + LayoutTreePacker shall pack 2+ independently-placed layout trees into one combined tree with + no two trees' bounding boxes overlapping. + justification: | + AutoLayoutAlgorithm routes different components of the same graph through different leaf + algorithms and needs a single combined tree to hand to a renderer; the packer must guarantee + the merged pieces never overlap regardless of how many trees or how they wrap onto new shelves. + tests: + - Pack_TwoTrees_PlacesSecondBesideFirstWithoutOverlap + - Pack_ManyTrees_WrapsOntoNewShelfWithoutOverlap + + - id: Rendering-Layout-LayoutTreePacker-TranslatesCoordinates + title: >- + LayoutTreePacker shall shift every placed box's coordinates in a packed sub-tree by that + sub-tree's assigned shelf offset. + justification: | + A sub-tree is laid out independently starting at its own local origin; without translating + every box's absolute coordinate by the assigned shelf offset, packed pieces would collapse + onto the same coordinates instead of forming one coherent combined canvas. + tests: + - Pack_TwoTrees_PlacesSecondBesideFirstWithoutOverlap + + - id: Rendering-Layout-LayoutTreePacker-TranslatesNestedChildren + title: >- + LayoutTreePacker shall recursively translate a placed box's nested children by the same + shelf offset as their parent. + justification: | + LayoutTree coordinates are absolute, not parent-relative, so a container box produced by the + hierarchical algorithm has its own children already placed in absolute coordinates; packing + the container without recursing into its children would leave the nested content at the + wrong (pre-pack) position. + tests: + - Pack_BoxWithNestedChildren_TranslatesChildrenRecursively + + - id: Rendering-Layout-LayoutTreePacker-TranslatesWaypoints + title: >- + LayoutTreePacker shall translate every waypoint of a packed sub-tree's connector lines, and + the centre coordinate of every packed sub-tree's port, by that sub-tree's shelf offset. + justification: | + A connector line or port anchor left untranslated would visually detach from the boxes it + connects to once those boxes are shifted to their packed position. + tests: + - Pack_TreeWithLine_TranslatesEveryWaypoint + - Pack_TreeWithPort_TranslatesCentre + + - id: Rendering-Layout-LayoutTreePacker-DegenerateCases + title: >- + LayoutTreePacker shall return a zero-size, empty tree for an empty list of sub-trees, and + shall return a single-tree list unchanged without any translation. + justification: | + An empty or single-tree input needs no packing at all; returning them unchanged avoids + needless translation work and guarantees a lone tree's coordinates are never perturbed. + tests: + - Pack_EmptyList_ReturnsEmptyTree + - Pack_SingleTree_ReturnsItUnchanged + + - id: Rendering-Layout-LayoutTreePacker-UnsupportedNodeType + title: >- + LayoutTreePacker shall throw NotSupportedException when a sub-tree contains a LayoutNode + subtype outside the closed set (LayoutBox, LayoutLine, LayoutPort) the bundled leaf + algorithms are confirmed to emit, rather than silently skipping the unrecognized node. + justification: | + Silently leaving an unrecognized node's coordinates untranslated would place it at the wrong + (pre-pack) position with no visible sign of the error; failing loudly instead names the + offending type immediately, a deliberate divergence from the renderer's "skip unknown types" + convention since a packer (unlike a renderer) cannot partially succeed without corrupting + coordinates. + tests: + - Pack_UnrecognizedNodeType_ThrowsNotSupportedException + + - id: Rendering-Layout-LayoutTreePacker-PreservesWarnings + title: >- + LayoutTreePacker shall preserve every packed sub-tree's Warnings in the combined tree. + justification: | + A leaf algorithm may report diagnostic warnings on its placed tree; discarding them during + packing would silently drop information a caller relies on for diagnosing layout issues. + tests: + - Pack_TreesWithWarnings_PreservesAllWarnings + + - id: Rendering-Layout-LayoutTreePacker-Validation + title: >- + LayoutTreePacker shall reject a null trees argument with an argument-null error. + justification: | + Failing fast on a null argument prevents undefined behavior and surfaces caller errors at the + engine boundary. + tests: + - Pack_NullTrees_ThrowsArgumentNullException diff --git a/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml b/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml index 521a446..7e90bd7 100644 --- a/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml +++ b/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml @@ -89,7 +89,8 @@ sections: - id: Rendering-Layout-OrthogonalEdgeRouter-AvoidsExtendedSoftOverlap title: >- When an alternate lane is reachable at bounded extra cost, OrthogonalEdgeRouter shall not - route a segment along an extended overlap with an existing soft obstacle. + route a segment along an extended overlap with an existing soft obstacle, and shall not need + to leave the source/target envelope to find that alternate lane. justification: | Several parallel connectors that each collapse their long corridor onto the same grid line visually merge into one thick trunk for the length of the overlap, which reads as a single @@ -97,7 +98,46 @@ sections: tolerated (steering every trivial touch away would force pointless detours and would conflict with the legitimate shared endpoint-adjacent approach corridor), but an overlap that extends for a substantial span must be priced high enough that a bounded-cost lane - change is preferred instead. + change is preferred instead. When many parallel connectors share a narrow inter-box gap, + each successive connector's own soft-obstacle avoidance must not offset its candidate lane + further and further outward with no ceiling: left unbounded, that stair-stepping eventually + lands a lane behind a box already left, producing a route that leaves toward the gap, + reverses back underneath that box, then recovers — a visibly tangled, self-crossing path. + Soft-obstacle-derived candidate lines are therefore clamped to the source/target envelope on + any axis with a meaningful span, and any residual A* move that still lands outside that + envelope is charged a proportional cost (never a hard block), so sharing a saturated + narrow-gap lane is preferred over an unrelated "free" detour once every in-gap lane is + exhausted. tests: - RouteWithStatus_LongSoftObstacleOverlap_PrefersAlternateLane - RouteWithStatus_ShortSoftObstacleOverlap_KeepsStraightRoute + - RouteWithStatus_SaturatedNarrowGapSoftObstacles_StaysWithinEnvelope + + - id: Rendering-Layout-OrthogonalEdgeRouter-AvoidsConnectorCrossing + title: >- + When a bounded-cost, non-crossing alternative exists, OrthogonalEdgeRouter shall not route a + segment transversally across an already-routed connector's soft-obstacle trunk. + justification: | + A soft obstacle's own aspect ratio (a fixed ~2px thin dimension on the perpendicular axis, + the segment's length on its long axis — see ConnectorRouter.AddLineObstacles) reliably + classifies its orientation. Before this fix, a candidate move that ran perpendicular to a + soft obstacle (visibly cutting across another connector's trunk at a right angle, an "X" + crossing) was priced by the same overlap formula used for a parallel shared-lane overlap; + that formula's overlap span is bounded by the obstacle's own thin dimension regardless of how + long or visually significant the crossing move itself is, so a genuine connector-to-connector + crossing was underpriced at roughly a trivial few-pixel overlap — cheaper than a single turn + and far cheaper than most alternate-lane detours, so the router had no incentive to avoid it. + The router now detects this perpendicular relationship separately (requiring the candidate + move to fully traverse both edges of the obstacle's thin band, not merely graze one edge of + it, so a legitimate short lane-change move is never mistaken for a crossing) and prices it + with a dedicated flat cost, so a route with a genuinely bounded-cost non-crossing alternative + takes it. This is a cost, not a hard block: in a sufficiently saturated narrow-gap scenario + where no non-crossing alternative is reachable at all (for example when a connector's own + soft obstacles are only known for already-routed connectors earlier in the same routing + batch, not later ones), a crossing may still be the only path found — this fix corrects the + pricing gap for cases where an alternative genuinely exists, and does not change the + batch-level routing order that determines which connectors are visible to each other as + obstacles. + tests: + - RouteWithStatus_VerticalOrientedSoftObstacleAcrossOneLRoute_PrefersNonCrossingAlternative + - Route_NineParallelEdgesIntoCompartmentBox_DoNotLoopBehindEitherBox diff --git a/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml index 90ff4c3..1dd0fb8 100644 --- a/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml +++ b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml @@ -76,23 +76,70 @@ sections: tests: - Apply_CrossContainerEdge_RoutesAroundInterveningContainer + - id: Rendering-Layout-HierarchicalLayout-SiblingContainerGapWidening + title: >- + On the no-boundary-port placement path, HierarchicalLayoutAlgorithm shall widen the horizontal + gap between two horizontally-adjacent sibling containers to the connector-corridor width for the + number of cross-container edges routed between them. + justification: | + Cross-container edges never appear in the per-scope sized view the leaf algorithm lays out, so + the leaf alone cannot reserve room for a fan of them between two sibling containers. A + post-placement pass over the composed boxes, reusing the shared corridor-width formula + (EdgeCountGapWidener), widens only the horizontal gap of a genuinely side-by-side pair so the + connectors get distinct lanes. + tests: + - Apply_SiblingContainersWithEightCrossEdges_WidensGapToCorridorWidth + + - id: Rendering-Layout-HierarchicalLayout-SiblingContainerGapWideningSingleEdgeUnaffected + title: >- + The sibling-container gap widening shall leave a pair joined by zero or one cross-container + edge byte-identical to its prior placement. + justification: | + Gating the widening pass on more than one edge keeps every existing single-edge and no-edge + scope byte-identical to its behavior before the widening feature existed, since a lone + connector never needed a widened corridor. + tests: + - Apply_SiblingContainersWithSingleCrossEdge_LeavesGapUnwidened + + - id: Rendering-Layout-HierarchicalLayout-SiblingGapWideningPortPathUnaffected + title: >- + The sibling-container gap widening pass shall leave the boundary-port combined-pass path + unchanged, including the resolved shared anchor for parallel boundary-port approaches. + justification: | + The boundary-port combined recursive pass produces its geometry from a different pipeline + (the merge-region assembly and decomposition) than the no-boundary-port placement path the + widening pass runs on; confining the widening pass to the no-boundary-port branch keeps the + combined pass, and the anchors it resolves, untouched. + tests: + - Apply_BoundaryPortWithParallelApproaches_ResolvesToOneSharedAnchorUnaffectedByWidening + - HierarchicalLayoutAlgorithm_NoBoundaryPortHierarchy_OutputIsByteIdenticalBeforeAndAfterChange + - id: Rendering-Layout-HierarchicalLayout-CascadesOptions title: >- - HierarchicalLayoutAlgorithm shall resolve every CoreOptions property (Algorithm, Direction, - EdgeRouting, HierarchyHandling, NodeSpacing, LayerSpacing) for a scope by walking to the + HierarchicalLayoutAlgorithm shall resolve a CoreOptions property for a scope by walking to the nearest ancestor scope with an explicit override, falling back to the property's own default only when no scope in the chain declares one. justification: | A leaf algorithm reads a CoreOptions property from the options it is given, in preference to - the shared root options. Without a generalized per-scope cascade, a caller-supplied override on - a nested container would be silently dropped and the container would fall back to the parent - options' value (or the property default) instead — as previously happened for every property - except Algorithm. Generalizing the mechanism (via PropertyHolder.OverlayOnto) fixes this for - all six properties at once rather than one at a time. + the shared root options. Without a per-scope cascade, a caller-supplied override on a nested + container would be silently dropped and the container would fall back to the parent options' + value (or the property default) instead. tests: - Apply_ContainerWithDirectionOverride_HonorsNestedDirection - Apply_ThreeLevelDirectionCascade_InheritsThroughUnsetMiddleLevel - Apply_ThreeLevelDirectionCascade_MidLevelOverrideTakesPrecedence + + - id: Rendering-Layout-HierarchicalLayout-CascadesAllProperties + title: >- + HierarchicalLayoutAlgorithm shall generalize the per-scope cascade to every CoreOptions + property (Algorithm, Direction, EdgeRouting, HierarchyHandling, NodeSpacing, LayerSpacing), not + only Direction, reaching every leaf-algorithm call at every nesting level. + justification: | + Generalizing the cascade mechanism (via PropertyHolder.OverlayOnto) fixes the ancestor-override + resolution for all six properties at once rather than one at a time — previously the cascade + existed only for Algorithm, so a nested EdgeRouting (or other property) override was silently + dropped even though a nested Direction override already worked correctly. + tests: - Apply_ThreeLevelEdgeRoutingCascade_ReachesEveryLeafAlgorithmCall - id: Rendering-Layout-HierarchicalLayout-HonorsScopeEdgeRouting @@ -128,29 +175,57 @@ sections: HierarchicalLayoutAlgorithm shall resolve a container's boundary (delegation) port — a port the container both exposes to an external approach edge and references from an edge inside its own child scope — to exactly one shared LayoutPort anchor on the container boundary - carrying both the external and internal labels, with every converging edge — every external - approach and every internal delegation edge — routed through the real orthogonal corridor - router onto that one anchor as an axis-aligned path with no direct diagonal, including under - external and internal fan-out, for two independent boundary ports on one container, and - through a multi-level delegation chain. + carrying both the external and internal labels. justification: | A boundary/delegation port is the bulkhead-connector pattern: one physical anchor names both the connection reaching it from outside the container and the delegation relaying it into the - container's own children. The container and its children are laid out in one combined - recursive layered pass — assembled into a merge region, run through the recursive pipeline, - and projected back by the merge-region decomposer — so every converging edge is routed by the - same orthogonal corridor router that routes ordinary edges, rather than having an endpoint - patched onto the anchor with a hand-built diagonal. Consolidating fan-out and both labels onto - a single anchor (rather than emitting a separate port per edge) keeps the rendered wiring - unambiguous, and routing every converging edge orthogonally through the real router is what - keeps the external fan-in and the multi-level delegation chain free of the diagonal shortcut a - post-hoc endpoint reconciliation produced. + container's own children. Consolidating both labels onto a single anchor (rather than emitting + a separate port per edge) keeps the rendered wiring unambiguous. tests: - Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels + + - id: Rendering-Layout-HierarchicalLayout-BoundaryPortFanMerge + title: >- + HierarchicalLayoutAlgorithm shall consolidate every edge converging on a container's boundary + port — every external approach and every internal delegation edge, under external and internal + fan-out — onto the single shared anchor for that port rather than emitting a separate anchor + per edge. + justification: | + Emitting a separate port per converging edge would defeat the single-anchor bulkhead-connector + pattern under fan-out; consolidating fan-out onto one anchor keeps the rendered wiring + unambiguous regardless of how many edges converge on the boundary port. + tests: - Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor + + - id: Rendering-Layout-HierarchicalLayout-IndependentBoundaryPorts + title: >- + HierarchicalLayoutAlgorithm shall resolve two independent boundary ports on one container to + two distinct LayoutPort anchors, preserving each anchor's own connector provenance even when + the two ports share a null or identical external label. + justification: | + Two independent boundary ports on the same container name two physically distinct connection + points; consolidating them onto a single anchor (as fan-out consolidation does for edges of the + same port) would incorrectly merge unrelated connections, including when their external labels + happen to be null or identical text. + tests: - Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors - Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_PreservesConnectorProvenance - Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance + + - id: Rendering-Layout-HierarchicalLayout-BoundaryPortOrthogonal + title: >- + Every edge converging on a boundary (delegation) port anchor shall be routed through the real + orthogonal corridor router onto that anchor as an axis-aligned path with no direct diagonal, + including under external fan-in and through a multi-level delegation chain. + justification: | + The container and its children are laid out in one combined recursive layered pass — assembled + into a merge region, run through the recursive pipeline, and projected back by the + merge-region decomposer — so every converging edge is routed by the same orthogonal corridor + router that routes ordinary edges, rather than having an endpoint patched onto the anchor with + a hand-built diagonal. Routing every converging edge orthogonally through the real router is + what keeps the external fan-in and the multi-level delegation chain free of the diagonal + shortcut a post-hoc endpoint reconciliation produced. + tests: - MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal - MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal - HierarchicalLayoutAlgorithm_ThreeLevelDelegationChain_EndToEnd_ProducesConnectedOrthogonalPath diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 1b51211..924b5f4 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -54,8 +54,8 @@ Installing a renderer or the layout package transitively brings in the model and element, or a free-standing options object. - **`ILayoutAlgorithm`** — consumes a `LayoutGraph` plus `LayoutOptions` and produces a placed `LayoutTree`. Bundled implementations are `LayeredLayoutAlgorithm` (id `"layered"`), - `ContainmentLayoutAlgorithm` (id `"containment"`), and the recursive `HierarchicalLayoutAlgorithm` - (id `"hierarchical"`). + `ContainmentLayoutAlgorithm` (id `"containment"`), the recursive `HierarchicalLayoutAlgorithm` + (id `"hierarchical"`), and the auto-routing `AutoLayoutAlgorithm` (id `"auto"`). - **`LayoutEngine`** — the batteries-included facade (in `DemaConsulting.Rendering.Layout`). One call, `LayoutEngine.Layout(graph)`, lays out a graph with the algorithm it declares, resolved from the bundled algorithms. It handles both flat and nested graphs (see *Quickstart* below). @@ -493,16 +493,145 @@ using var output = File.Create("nested.svg"); renderer.Render(tree, new RenderOptions(Themes.Light), output); ``` +## Laying out a graph of unknown shape with the auto meta-algorithm + +When a diagram's shape is not known in advance — a mixture of connected clusters, nested containers, +and unrelated singleton nodes, or a graph assembled programmatically from independent pieces — select +the bundled `AutoLayoutAlgorithm` (id `"auto"`) instead of choosing a leaf algorithm by hand. It splits +the graph into its connected top-level components, routes each component to whichever bundled leaf +algorithm best suits its shape (`hierarchical` for any component with a container node, `layered` for +a connected cluster, `containment` for the shared bucket of childless, edgeless singletons), lays each +piece out independently, and packs the results into one combined canvas: + +```csharp +// A connected cluster plus two unrelated singleton boxes. +var graph = new LayoutGraph(); +var a = graph.AddNode("a", width: 80, height: 40); +var b = graph.AddNode("b", width: 80, height: 40); +graph.AddEdge("a-b", a, b); +graph.AddNode("solo1", width: 80, height: 40); +graph.AddNode("solo2", width: 80, height: 40); + +// The cluster routes to "layered"; the two singletons pack together via "containment". +graph.Set(CoreOptions.Algorithm, "auto"); +var tree = LayoutEngine.Layout(graph); + +// Render the composed tree with any renderer. +var renderer = new SvgRenderer(); +using var output = File.Create("auto.svg"); +renderer.Render(tree, new RenderOptions(Themes.Light), output); +``` + +For a graph that is already a single, fully (or mostly) connected component (the common case), `"auto"` +delegates directly to the equivalent leaf algorithm with no splitting or copying cost, so it is a safe +default even when you are not sure a graph is disconnected. + +### Algorithm capabilities at a glance + +The four bundled algorithms differ in which layout concerns they handle. The two matrices below record, +for each concern, whether an algorithm supports it (`Yes`), does not (`No`), supports it only in +specific conditions (`Partial`), or deliberately leaves it to a different layer of the pipeline +(`N/A by design`). The one-line reason in each cell names the mechanism; the notes after the tables +expand the cells that carry a condition. The matrix is split across two tables — the leaf algorithms +first, then `hierarchical` and `auto` — purely so each row stays readable. Every cell was verified +against the current source. + +`auto` implements none of these concerns itself: it splits the graph into connected components, routes +each to a bundled leaf algorithm (`hierarchical` for a component containing a container node, `layered` +for a connected cluster, `containment` for the shared bucket of edgeless singletons), and packs the +results. Each `auto` cell therefore reads `Yes, via delegation` — it exposes exactly the capability +(or the absence of one) of whichever leaf `AutoLayoutAlgorithm`'s routing rule assigns to a component, +never a capability of its own. + +| Concern | `layered` | `containment` | +| --- | --- | --- | +| Horizontal gap widening (edge count) | Yes — BK inter-layer corridor | Yes — Task 1 same-row pairs | +| Vertical gap widening (edge count) | Partial — only TB/BT flow | No — out of scope by design | +| Container auto-expansion | N/A by design — flat leaf | N/A by design — flat leaf | +| Port/label space reservation | Yes — PortDistributor reserve | No — leaf boxes only | +| Direction/orientation support | Yes — `CoreOptions.Direction` | No — reading order only | +| Connectivity/component awareness | Yes — Sugiyama layering | No — reading order only | +| Aspect-ratio/canvas-shape targeting | Partial — multi-component only | Yes — 4:3 heuristic | +| Nested/recursive containment | No — children opaque | No — children opaque | + +| Concern | `hierarchical` | `auto` | +| --- | --- | --- | +| Horizontal gap widening (edge count) | Yes — Task 2 sibling gap | Yes, via delegation | +| Vertical gap widening (edge count) | No — deferred, out of scope | Yes, via delegation | +| Container auto-expansion | Yes — cascading sizing | Yes, via delegation | +| Port/label space reservation | Yes — via `layered` leaf | Yes, via delegation | +| Direction/orientation support | Yes — per-scope cascade | Yes, via delegation | +| Connectivity/component awareness | Yes — leaf plus routing | Yes, via delegation | +| Aspect-ratio/canvas-shape targeting | Partial — via leaf/packer | Yes, via delegation | +| Nested/recursive containment | Yes — defining feature | Yes, via delegation | + +Notes on the conditional cells: + +- **`layered`, vertical gap widening — Partial.** `BrandesKopfPlacer` widens the corridor *between + adjacent layers* to `2·ConnectorClearance + (n − 1)·EdgeSpacing` for a fan of `n` edges, exactly the + formula the containment and hierarchical passes now reuse. That corridor is horizontal under the + default left-to-right flow and only becomes a vertical gap under a top-to-bottom or bottom-to-top + `Direction`; the perpendicular within-layer spacing is always a fixed node spacing, never widened by + edge count. +- **`layered`, aspect-ratio targeting — Partial.** A single connected graph has no aspect target — its + dimensions follow from its layers. Only when `layered` packs *several disconnected components* does + its `ComponentPacker` bias the arrangement toward a target aspect (≈ 1.25). +- **`hierarchical`, aspect-ratio targeting — Partial.** `hierarchical` has no aspect target of its own; + each scope inherits whatever its leaf algorithm targets (`containment`'s 4:3, or `layered`'s + multi-component bias), and a container grows to fit its children rather than to a shape goal. +- **`hierarchical`, port/label and connectivity — Yes via the leaf.** Each scope is laid out by a leaf + algorithm (`layered` by default), so it inherits that leaf's port reservation and connectivity + awareness; selecting `containment` as the per-scope leaf would inherit that algorithm's `No` instead. + +**Caveat on the `auto` row.** Because `auto` delegates every concern, its correctness on any row is +contingent on the leaf algorithm it routes a component to: an `auto` cell reads `Yes, via delegation` +to mean "faithfully inherits the delegated leaf's behaviour", not "implemented by `auto`". Where the +delegated leaves do not provide a capability — as in the vertical-gap-widening row, which no leaf +widens on that axis today — `auto` does not provide it either, and the guarantee is only as strong as +the leaf's. The `containment` column's two `N/A by design` rows (container auto-expansion) are never +reached through `auto`: `AutoLayoutAlgorithm` routes any component that owns a container to +`hierarchical`, so `containment` only ever sees the flat singleton bucket, making those rows +structurally unreachable via `auto`'s dispatch rather than a capability `auto` would ever need from +that leaf. Finally, the two edge-count gap-widening rows (horizontal and vertical) became a *real, +verified* delegated guarantee only after this project extended `containment` (Task 1) and +`hierarchical` (Task 2) with the shared corridor-width formula; before that work, an `auto` graph +routed to those leaves fanned parallel connectors through an un-widened channel, so the "Yes, via +delegation" in the horizontal row now rests on a guarantee that the leaves actually keep. + +### When to deviate from auto and select an algorithm directly + +`auto` is the right default whenever a diagram's shape is not known in advance, but a caller who knows +the shape can select a leaf algorithm directly to get a specific result `auto`'s dispatch would not +choose: + +- **Select `layered` directly** for a graph whose top-level nodes share a rank yet are *not* connected + into one component — for example several independent flows you want laid out on shared layers in one + coordinated pass. `auto` would split those disconnected components apart and pack them as separate + blocks; calling `layered` yourself keeps them on common layers and lets you force a specific + `Direction`. +- **Select `containment` directly** for a known-flat set of peer boxes when you want the packed grid to + target an aspect-ratio/canvas shape (its 4:3 heuristic) rather than a connectivity-driven arrangement + — placement then follows reading order and box area, not edges. +- **Select `hierarchical` directly** for a graph with nested containers when you need explicit control + over container sizing and recursion — for example to cascade a non-default per-scope leaf algorithm, + which `auto` never does because it always applies `hierarchical`'s own default leaf resolution. + +For the exact per-component routing rule `auto` applies — which component goes to which leaf — see the +auto-routing meta-algorithm's design documentation. `auto` remains the recommended default, but it is +not a substitute for understanding the output shape you want: when you already know a graph is a shared- +rank disconnected set, a flat peer grid, or an explicitly sized nested hierarchy, selecting the leaf +algorithm directly gives a result `auto`'s shape-agnostic dispatch is not designed to produce. + ## Selecting algorithms and renderers by registry For applications that choose an algorithm or output format at run time, register the implementations once and resolve them by id, media type, or output file extension. The bundled algorithms already have a factory — `LayoutAlgorithms.CreateDefaultRegistry()` returns a registry with -`layered`, `containment`, and `hierarchical` pre-registered — so you only assemble a registry by hand -when you want to add custom algorithms: +`layered`, `containment`, `hierarchical`, and `auto` pre-registered — so you only assemble a registry +by hand when you want to add custom algorithms: ```csharp -// Shortcut: a registry with the three bundled algorithms already registered. +// Shortcut: a registry with the four bundled algorithms already registered. var algorithms = LayoutAlgorithms.CreateDefaultRegistry(); algorithms.Register(new MyTreeLayoutAlgorithm()); // extend it with your own @@ -530,8 +659,9 @@ one-call convenience while resolving against your own set of algorithms. 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 +cross-container edge, auto-routed diagrams mixing clusters/containers/singletons, 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. diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index 58fd464..8347719 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -64,9 +64,16 @@ matrix linking each requirement to the passing tests named in its verification s In-house items have artifacts in these parallel locations: -- Requirements: `docs/reqstream/{system}/{system}.yaml` (kebab-case) -- Design docs: `docs/design/{system}/{system}.md` (kebab-case) -- Verification design: `docs/verification/{system}/{system}.md` (kebab-case) +- Requirements: `docs/reqstream/{system-name}.yaml` (system-level) and + `docs/reqstream/{system-name}/{unit-name}.yaml` (unit-level, kebab-case; nested one level + further under a subsystem folder when the unit belongs to one, e.g. + `docs/reqstream/{system-name}/{subsystem-name}/{unit-name}.yaml`) +- Design docs: `docs/design/{system-name}.md` (system-level) and + `docs/design/{system-name}/{unit-name}.md` (unit-level, kebab-case; nested under a subsystem + folder the same way as requirements) +- Verification design: `docs/verification/{system-name}.md` (system-level) and + `docs/verification/{system-name}/{unit-name}.md` (unit-level, kebab-case; nested under a + subsystem folder the same way as requirements) - Source code: `src/{System}/.../{Item}.cs` (PascalCase for C#) - Tests: `test/{System}.Tests/.../{Item}Tests.cs` (PascalCase for C#) diff --git a/docs/verification/ots/sonarscanner.md b/docs/verification/ots/sonarscanner.md new file mode 100644 index 0000000..6f4f854 --- /dev/null +++ b/docs/verification/ots/sonarscanner.md @@ -0,0 +1,44 @@ +## SonarScanner for .NET Verification + +This document provides the verification evidence for the SonarScanner for .NET OTS software item. +Requirements for this OTS item are defined in the SonarScanner for .NET OTS Software Requirements +document. + +### Required Functionality + +The dotnet-sonarscanner local tool wraps the build with begin/end analysis steps that upload +source metrics, coverage, and issues to SonarCloud. It runs in the same CI pipeline that produces +the TRX test results consumed by SonarMark, so a successful pipeline run is evidence that +SonarScanner for .NET executed without error. + +### Verification Approach + +SonarScanner for .NET is verified by the CI pipeline itself: the pipeline runs +`dotnet sonarscanner begin` before the build and `dotnet sonarscanner end` after tests complete, +uploading the analysis to SonarCloud. A CI build failure at either step is evidence that +SonarScanner for .NET did not analyze and publish the build correctly; a successful pipeline run, +combined with SonarMark's downstream retrieval of the published quality-gate data, is evidence +that the analysis was uploaded correctly. + +### Test Scenarios + +#### SonarScanner_BeginAnalysis + +**Scenario**: The CI pipeline runs `dotnet sonarscanner begin` before the build. + +**Expected**: Exits 0 and prepares the workspace for analysis. + +**Requirement coverage**: `Rendering-OTS-SonarScanner`. + +#### SonarScanner_EndAnalysisPublishesResults + +**Scenario**: The CI pipeline runs `dotnet sonarscanner end` after the build and tests complete. + +**Expected**: Exits 0 and publishes the analysis results to SonarCloud, where SonarMark +subsequently retrieves them. + +**Requirement coverage**: `Rendering-OTS-SonarScanner`. + +### Requirements Coverage + +- **`Rendering-OTS-SonarScanner`**: SonarScanner_BeginAnalysis, SonarScanner_EndAnalysisPublishesResults diff --git a/docs/verification/rendering-layout.md b/docs/verification/rendering-layout.md index 1db36dc..757724a 100644 --- a/docs/verification/rendering-layout.md +++ b/docs/verification/rendering-layout.md @@ -12,13 +12,15 @@ linked documents below; this file maps only the Rendering.Layout system requirem - HierarchicalLayoutAlgorithm Unit Verification - DefaultLayout Unit Verification - LayeredLayoutAlgorithm Unit Verification +- AutoLayoutAlgorithm Unit Verification ## Verification Approach Rendering.Layout is verified through deterministic in-process xUnit tests over synthetic layout graphs and geometry inputs. System coverage is established by representative scenarios for each public system capability: named layered layout, reusable geometric engines, the staged pipeline, connector routing, -containment placement, containment and hierarchical algorithms, and the default layout facade. +containment placement, containment and hierarchical algorithms, the default layout facade, and the +auto-routing meta-algorithm. Legacy-oracle and byte-identity routing tests remain unit-level evidence in the linked verification documents. This system document records only the acceptance criteria and system-requirement coverage. @@ -64,6 +66,9 @@ verification files; representative system-level coverage is: - **`Rendering-Layout-HierarchicalLayout`**: `Id_IsHierarchical`, `Apply_FlatRandomGraphs_MatchLayeredAlgorithmExactly` (see HierarchicalLayoutAlgorithm Unit Verification). -- **`Rendering-Layout-DefaultLayout`**: `CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms`, +- **`Rendering-Layout-DefaultLayout`**: `CreateDefaultRegistry_RegistersOnlyTheFourBundledAlgorithms`, `Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly` (see DefaultLayout Unit Verification). +- **`Rendering-Layout-AutoAlgorithm`**: `Id_IsAuto`, + `Apply_ClusterPlusIsolatedSingletons_PacksBothGroupsWithoutOverlap` (see + AutoLayoutAlgorithm Unit Verification). diff --git a/docs/verification/rendering-layout/auto-layout-algorithm.md b/docs/verification/rendering-layout/auto-layout-algorithm.md new file mode 100644 index 0000000..4c6a721 --- /dev/null +++ b/docs/verification/rendering-layout/auto-layout-algorithm.md @@ -0,0 +1,105 @@ +## AutoLayoutAlgorithm Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the auto-layout-algorithm unit requirements to named test scenarios. + +### Verification Approach + +`AutoLayoutAlgorithm` is verified by direct xUnit unit tests that call `Apply(graph)` on synthetic +`LayoutGraph` inputs, composing the real bundled leaf algorithms (`LayeredLayoutAlgorithm`, +`ContainmentLayoutAlgorithm`, `HierarchicalLayoutAlgorithm`) rather than mocks, so routing, the +fast-path equivalence guarantee, and the multi-group split/pack path are all observed on production +code paths. A dump-string comparison helper (mirroring the pattern in +`HierarchicalLayoutAlgorithmTests.cs`) is used wherever two `LayoutTree` instances must be compared for +content equality, since record equality on a `LayoutTree` falls back to reference equality for its +`List`-backed members. + +### Test Environment + +- **Framework**: xUnit v3 running on the .NET SDK. +- **Runner**: `dotnet test` invoked by `build.ps1` and the CI pipeline. +- **Project**: `test/DemaConsulting.Rendering.Layout.Tests/AutoLayoutAlgorithmTests.cs`. +- **Dependencies**: no external services, files, or network access; every test constructs its own + in-memory `LayoutGraph` and `LayoutOptions` instances. +- **Isolation**: each test builds its own inputs; the algorithm is stateless between calls. + +### Acceptance Criteria + +A verification run passes when every named scenario below asserts without unexpected exception, and +the referenced tests cover each `Rendering-Layout-AutoAlgorithm-*` requirement. Any drift in the stable +identifier (`"auto"`), in the component-routing rule, in the fast-path byte-identity guarantee, in the +multi-group packing behavior, in cascaded-option handling, in empty-graph handling, or in the +argument-null validation behavior constitutes a failure. + +### Test Scenarios + +- **Identity** (`Rendering-Layout-AutoAlgorithm-Identity`): `Id_IsAuto` asserts the algorithm reports + the stable `"auto"` identifier. +- **Routes connected cluster** (`Rendering-Layout-AutoAlgorithm-RoutesConnectedCluster`): + `Apply_SingleConnectedComponent_MatchesLayeredAlgorithmDirectly` confirms a single connected, + childless component matches the layered algorithm's own output exactly; + `Apply_SingleNodeWithSelfLoop_RoutesToLayeredNotContainment` confirms a self-loop-only node is routed + to layered rather than folded into the singleton bucket; `Apply_MultipleDisconnectedClusters_PacksEachClusterWithoutOverlap` + confirms multiple independent connected clusters are each routed through layered and packed without + overlapping. +- **Routes container** (`Rendering-Layout-AutoAlgorithm-RoutesContainer`): + `Apply_IsolatedNodeWithChildren_MatchesHierarchicalAlgorithmDirectly` confirms a single isolated + container node still routes to hierarchical; `Apply_NestedContainers_RoutesToHierarchical` confirms a + two-level-deep nesting is still recognized and routed to hierarchical rather than treated as flat. +- **Routes singleton bucket** (`Rendering-Layout-AutoAlgorithm-RoutesSingletonBucket`): + `Apply_AllIsolatedSingletons_MatchesContainmentAlgorithmDirectly` confirms a graph of nothing but + childless, edgeless singletons matches the containment algorithm's own output exactly; + `Apply_ClusterPlusIsolatedSingletons_PacksBothGroupsWithoutOverlap` confirms a mix of one connected + cluster and several singletons packs the cluster (layered) and the singleton bucket (containment) + without their bounding boxes overlapping. +- **Fast-path equivalence** (`Rendering-Layout-AutoAlgorithm-FastPathEquivalence`): the same tests + listed under connected-cluster, container, and singleton-bucket routing above each assert exact + (dump-string) equality against the corresponding leaf algorithm applied directly, confirming the + single-group fast path never splits, copies, or repacks a graph unnecessarily. +- **Multi-group packing** (`Rendering-Layout-AutoAlgorithm-MultiGroupPacking`): + `Apply_ClusterPlusIsolatedSingletons_PacksBothGroupsWithoutOverlap` and + `Apply_MultipleDisconnectedClusters_PacksEachClusterWithoutOverlap` confirm every group's placed + boxes stay within the combined canvas and no two groups' boxes overlap. +- **Cascades options** (`Rendering-Layout-AutoAlgorithm-CascadesOptions`): + `Apply_GraphLevelOptionOverride_AppliesToSplitComponents` confirms a graph-level cascaded option + still applies to a split-off component; `Apply_GraphDeclaresAutoAlgorithmWithNestedContainerGroup_DoesNotThrow` + is a regression test confirming that when the graph itself explicitly declares + `CoreOptions.Algorithm = "auto"` and the multi-group split routes one group to hierarchical with a + genuinely nested (2+ level) container, the algorithm-selection reset prevents the hierarchical + algorithm's own nested cascade from attempting to resolve `"auto"` and throwing a lookup error. +- **Empty graph** (`Rendering-Layout-AutoAlgorithm-EmptyGraph`): `Apply_EmptyGraph_ReturnsEmptyTree` + confirms an empty graph yields an empty placed layout tree. +- **Validation** (`Rendering-Layout-AutoAlgorithm-Validation`): `Apply_NullGraph_ThrowsArgumentNullException` + and `ApplyCore_NullOptions_ThrowsArgumentNullException` confirm null arguments are rejected with an + argument-null error. + +### Requirements Coverage + +- **`Rendering-Layout-AutoAlgorithm-Identity`**: + Id_IsAuto +- **`Rendering-Layout-AutoAlgorithm-RoutesConnectedCluster`**: + Apply_SingleConnectedComponent_MatchesLayeredAlgorithmDirectly, + Apply_SingleNodeWithSelfLoop_RoutesToLayeredNotContainment, + Apply_MultipleDisconnectedClusters_PacksEachClusterWithoutOverlap +- **`Rendering-Layout-AutoAlgorithm-RoutesContainer`**: + Apply_IsolatedNodeWithChildren_MatchesHierarchicalAlgorithmDirectly, + Apply_NestedContainers_RoutesToHierarchical +- **`Rendering-Layout-AutoAlgorithm-RoutesSingletonBucket`**: + Apply_AllIsolatedSingletons_MatchesContainmentAlgorithmDirectly, + Apply_ClusterPlusIsolatedSingletons_PacksBothGroupsWithoutOverlap +- **`Rendering-Layout-AutoAlgorithm-FastPathEquivalence`**: + Apply_SingleConnectedComponent_MatchesLayeredAlgorithmDirectly, + Apply_AllIsolatedSingletons_MatchesContainmentAlgorithmDirectly, + Apply_IsolatedNodeWithChildren_MatchesHierarchicalAlgorithmDirectly, + Apply_NestedContainers_RoutesToHierarchical, Apply_SingleNodeWithSelfLoop_RoutesToLayeredNotContainment +- **`Rendering-Layout-AutoAlgorithm-MultiGroupPacking`**: + Apply_ClusterPlusIsolatedSingletons_PacksBothGroupsWithoutOverlap, + Apply_MultipleDisconnectedClusters_PacksEachClusterWithoutOverlap +- **`Rendering-Layout-AutoAlgorithm-CascadesOptions`**: + Apply_GraphLevelOptionOverride_AppliesToSplitComponents, + Apply_GraphDeclaresAutoAlgorithmWithNestedContainerGroup_DoesNotThrow +- **`Rendering-Layout-AutoAlgorithm-EmptyGraph`**: + Apply_EmptyGraph_ReturnsEmptyTree +- **`Rendering-Layout-AutoAlgorithm-Validation`**: + Apply_NullGraph_ThrowsArgumentNullException, ApplyCore_NullOptions_ThrowsArgumentNullException diff --git a/docs/verification/rendering-layout/containment-layout-algorithm.md b/docs/verification/rendering-layout/containment-layout-algorithm.md index 928c447..6cfa641 100644 --- a/docs/verification/rendering-layout/containment-layout-algorithm.md +++ b/docs/verification/rendering-layout/containment-layout-algorithm.md @@ -15,7 +15,9 @@ avoidance), and validation are all observed on production code paths. - **Framework**: xUnit v3 running on the .NET SDK. - **Runner**: `dotnet test` invoked by `build.ps1` and the CI pipeline. -- **Project**: `test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs`. +- **Project**: `test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs`, with the + shared corridor-width helper covered by + `test/DemaConsulting.Rendering.Layout.Tests/Engine/EdgeCountGapWidenerTests.cs`. - **Dependencies**: no external services, files, or network access; every test constructs its own in-memory `LayoutGraph` and `LayoutOptions` instances. - **Isolation**: each test builds its own inputs; the algorithm is stateless between calls. @@ -55,6 +57,19 @@ behavior constitutes a failure. override carried on the graph itself is honored (routing still succeeds) even when the supplied options declares no routing style, mirroring `LayeredLayoutAlgorithm`'s graph-then-options resolution of `CoreOptions.Direction`. +- **Edge-count gap widening** (`Rendering-Layout-ContainmentAlgorithm-EdgeCountGapWidening`): + `Apply_SameRowPeersWithParallelEdges_WidensGapPastNodeSpacing` confirms two peer boxes packed on the + same row and joined by many parallel edges are spread apart by more than the default node spacing. +- **Vertical stack unaffected by gap widening** + (`Rendering-Layout-ContainmentAlgorithm-VerticalStackUnaffected`): + `Apply_VerticalStackWithParallelEdges_LeavesBoxPositionsUnchanged` confirms a vertically stacked + source-over-target pair keeps byte-identical box positions whether it carries nine edges or none, + proving the widening is horizontal-only. +- **Corridor width formula** (`Rendering-Layout-ContainmentAlgorithm-CorridorWidthFormula`): the + `EdgeCountGapWidener` tests (`Widen_ManyConnectors_ReturnsCorridorWidth`, `Widen_TwoConnectors`, + `Widen_SingleConnector`, `Widen_ZeroConnectors`, `Widen_BaseGapExceedsCorridor`) confirm the shared + formula the algorithm feeds the packer returns the connector-corridor width and never shrinks the + supplied base gap. ### Requirements Coverage @@ -74,3 +89,10 @@ behavior constitutes a failure. Apply_NullGraph_Throws, Apply_NullOptions_Throws - **`Rendering-Layout-ContainmentAlgorithm-HonorsScopeEdgeRouting`**: Apply_EdgeRoutingOverrideOnGraphScope_IsHonored +- **`Rendering-Layout-ContainmentAlgorithm-EdgeCountGapWidening`**: + Apply_SameRowPeersWithParallelEdges_WidensGapPastNodeSpacing +- **`Rendering-Layout-ContainmentAlgorithm-VerticalStackUnaffected`**: + Apply_VerticalStackWithParallelEdges_LeavesBoxPositionsUnchanged +- **`Rendering-Layout-ContainmentAlgorithm-CorridorWidthFormula`**: + Widen_ManyConnectors_ReturnsCorridorWidth, Widen_TwoConnectors, Widen_SingleConnector, + Widen_ZeroConnectors, Widen_BaseGapExceedsCorridor diff --git a/docs/verification/rendering-layout/containment-layout.md b/docs/verification/rendering-layout/containment-layout.md index f1c3047..8e81cc4 100644 --- a/docs/verification/rendering-layout/containment-layout.md +++ b/docs/verification/rendering-layout/containment-layout.md @@ -15,7 +15,9 @@ ordering, wrapping, region sizing, and field preservation are all observed on pr - **Framework**: xUnit v3 running on the .NET SDK. - **Runner**: `dotnet test` invoked by `build.ps1` and the CI pipeline. -- **Project**: `test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs`. +- **Project**: `test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs`, with the + underlying engine also covered by + `test/DemaConsulting.Rendering.Layout.Tests/Engine/ContainmentPackerTests.cs`. - **Dependencies**: no external services, files, or network access; every test constructs its own in-memory child list and `ContainmentOptions` instance. - **Isolation**: each test builds its own inputs; the unit is stateless between calls. @@ -53,6 +55,17 @@ invalid input constitutes a failure. - **Option defaults** (`Rendering-Layout-ContainmentLayout-Defaults`): `ContainmentOptions_Defaults_AreSensibleGapsAndPadding` confirms the default gaps are eight pixels and the default padding is twelve pixels. +- **Edge-count gap widening** (`Rendering-Layout-ContainmentLayout-EdgeCountGapWidening`): + `Pack_EdgeCounts_WidensIndicatedRowGap` and `Pack_SameRowEdgeCount_WidensHorizontalGap` confirm the + horizontal gap between two adjacent same-row children grows to the connector-corridor width for the + supplied edge count. +- **Edge-count gap widening opted out** (`Rendering-Layout-ContainmentLayout-EdgeCountGapWideningOptedOut`): + `Pack_WithoutEdgeCounts_UsesDefaultGap` and `Pack_NullEdgeCounts_ByteIdenticalToNoCounts` confirm + placement is byte-identical when no EdgeCounts lookup (or an explicit null lookup) is supplied. +- **Edge-count gap widening is row-scoped** + (`Rendering-Layout-ContainmentLayout-EdgeCountGapWideningRowScoped`): + `Pack_DifferentRowPair_Unaffected` confirms a pair the wrap decision splits across two rows is never + widened, regardless of its supplied edge count. - **Validation** (`Rendering-Layout-ContainmentLayout-Validation`): `Pack_NullChildren_Throws`, `Pack_NullOptions_Throws`, and `Pack_NullChildElement_Throws` confirm null arguments are rejected with an argument-null error. @@ -75,5 +88,11 @@ invalid input constitutes a failure. Pack_PreservesNonPositionFields - **`Rendering-Layout-ContainmentLayout-Defaults`**: ContainmentOptions_Defaults_AreSensibleGapsAndPadding +- **`Rendering-Layout-ContainmentLayout-EdgeCountGapWidening`**: + Pack_EdgeCounts_WidensIndicatedRowGap, Pack_SameRowEdgeCount_WidensHorizontalGap +- **`Rendering-Layout-ContainmentLayout-EdgeCountGapWideningOptedOut`**: + Pack_WithoutEdgeCounts_UsesDefaultGap, Pack_NullEdgeCounts_ByteIdenticalToNoCounts +- **`Rendering-Layout-ContainmentLayout-EdgeCountGapWideningRowScoped`**: + Pack_DifferentRowPair_Unaffected - **`Rendering-Layout-ContainmentLayout-Validation`**: Pack_NullChildren_Throws, Pack_NullOptions_Throws, Pack_NullChildElement_Throws diff --git a/docs/verification/rendering-layout/default-layout.md b/docs/verification/rendering-layout/default-layout.md index dc70976..6939fdb 100644 --- a/docs/verification/rendering-layout/default-layout.md +++ b/docs/verification/rendering-layout/default-layout.md @@ -37,10 +37,11 @@ null validation behavior constitutes a failure. - **Bundled algorithms** (`Rendering-Layout-DefaultRegistry-BundledAlgorithms`): `CreateDefaultRegistry_ResolvesLayeredAlgorithm`, `CreateDefaultRegistry_ResolvesContainmentAlgorithm`, - and `CreateDefaultRegistry_ResolvesHierarchicalAlgorithm` confirm each bundled algorithm resolves by - its identifier; `CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms` confirms exactly those - three identifiers are present; `CreateDefaultRegistry_ReturnsIndependentInstances` confirms registering - into one returned registry does not affect a registry from a separate call. + `CreateDefaultRegistry_ResolvesHierarchicalAlgorithm`, and `CreateDefaultRegistry_ResolvesAutoAlgorithm` + confirm each bundled algorithm resolves by its identifier; + `CreateDefaultRegistry_RegistersOnlyTheFourBundledAlgorithms` confirms exactly those four identifiers + are present; `CreateDefaultRegistry_ReturnsIndependentInstances` confirms registering into one + returned registry does not affect a registry from a separate call. - **Default algorithm** (`Rendering-Layout-LayoutEngine-DefaultAlgorithm`): `DefaultAlgorithmId_IsHierarchical` confirms the facade's declared default is `"hierarchical"`; `Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly` confirms an undeclared flat graph is @@ -67,7 +68,8 @@ null validation behavior constitutes a failure. - **`Rendering-Layout-DefaultRegistry-BundledAlgorithms`**: CreateDefaultRegistry_ResolvesLayeredAlgorithm, CreateDefaultRegistry_ResolvesContainmentAlgorithm, - CreateDefaultRegistry_ResolvesHierarchicalAlgorithm, CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms, + CreateDefaultRegistry_ResolvesHierarchicalAlgorithm, CreateDefaultRegistry_ResolvesAutoAlgorithm, + CreateDefaultRegistry_RegistersOnlyTheFourBundledAlgorithms, CreateDefaultRegistry_ReturnsIndependentInstances - **`Rendering-Layout-LayoutEngine-DefaultAlgorithm`**: DefaultAlgorithmId_IsHierarchical, Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly diff --git a/docs/verification/rendering-layout/engine/containment-packer.md b/docs/verification/rendering-layout/engine/containment-packer.md index 32ea78c..c2a3267 100644 --- a/docs/verification/rendering-layout/engine/containment-packer.md +++ b/docs/verification/rendering-layout/engine/containment-packer.md @@ -47,6 +47,9 @@ wrong single-item origin constitutes a failure. - **Single item** (`Rendering-Layout-ContainmentPacker-SingleItem`): `Pack_SingleItem_PositionsAtPaddingOrigin` confirms a lone item lands at the padding origin with the region sized to wrap it. +- **Null items** (`Rendering-Layout-ContainmentPacker-NullItems`): + `Pack_NullItems_ThrowsArgumentNullException` confirms a null `items` argument throws + `ArgumentNullException` rather than failing partway through the algorithm. #### Requirements Coverage @@ -64,3 +67,5 @@ wrong single-item origin constitutes a failure. Pack_EmptyList_ReturnsPaddingOnlyRegion - **`Rendering-Layout-ContainmentPacker-SingleItem`**: Pack_SingleItem_PositionsAtPaddingOrigin +- **`Rendering-Layout-ContainmentPacker-NullItems`**: + Pack_NullItems_ThrowsArgumentNullException diff --git a/docs/verification/rendering-layout/engine/edge-count-gap-widener.md b/docs/verification/rendering-layout/engine/edge-count-gap-widener.md new file mode 100644 index 0000000..f7ec421 --- /dev/null +++ b/docs/verification/rendering-layout/engine/edge-count-gap-widener.md @@ -0,0 +1,54 @@ +### EdgeCountGapWidener Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the edge-count-gap-widener unit requirements to named test scenarios. + +#### Verification Approach + +`EdgeCountGapWidener` is a stateless, pure static function, so verification is by direct xUnit unit +tests that call `Widen` with representative `baseGap`/`edgeCount` pairs and assert the exact returned +value. No mocks are used and no dependencies are injected: `LayeredLayoutMetrics.ConnectorClearance` +and `LayeredLayoutMetrics.EdgeSpacing` are the real production constants, so every test measures the +same corridor-width arithmetic the containment packer and the hierarchical algorithm consume. + +#### Test Environment + +- **Framework**: xUnit v3 running on the .NET SDK. +- **Runner**: `dotnet test` invoked by `build.ps1` and the CI pipeline. +- **Project**: `test/DemaConsulting.Rendering.Layout.Tests/Engine/EdgeCountGapWidenerTests.cs`. +- **Dependencies**: no external services, files, or network access; every test calls the static + `Widen` method directly with literal inputs. +- **Isolation**: each test is independent; the method holds no state between calls. + +#### Acceptance Criteria + +A verification run passes when every named scenario below asserts the exact expected `double` +without unexpected exception, and the referenced tests cover each +`Rendering-Layout-EdgeCountGapWidener-*` requirement. A returned value that narrows below the +supplied base gap, an incorrect corridor-width computation, or a degenerate edge count (zero or one) +that unexpectedly widens the gap constitutes a failure. + +#### Test Scenarios + +- **Corridor formula** (`Rendering-Layout-EdgeCountGapWidener-CorridorFormula`): + `Widen_ManyConnectors_ReturnsCorridorWidth` confirms a fan of eight connectors widens a small base + gap to the full corridor width (`2 * ConnectorClearance + (n - 1) * EdgeSpacing`); + `Widen_TwoConnectors_ReturnsSingleSlotCorridor` confirms two connectors widen the gap to a + single-slot corridor. +- **Degenerate cases** (`Rendering-Layout-EdgeCountGapWidener-DegenerateCases`): + `Widen_SingleConnector_ReturnsBaseGap` confirms one connector returns the base gap unchanged; + `Widen_ZeroConnectors_ReturnsBaseGap` confirms zero connectors likewise return the base gap + unchanged rather than narrowing it. +- **Never narrows** (`Rendering-Layout-EdgeCountGapWidener-NeverNarrows`): + `Widen_BaseGapExceedsCorridor_ReturnsBaseGap` confirms a base gap that already exceeds the computed + corridor width is preserved rather than shrunk to the corridor width. + +#### Requirements Coverage + +- **`Rendering-Layout-EdgeCountGapWidener-CorridorFormula`**: + Widen_ManyConnectors_ReturnsCorridorWidth, Widen_TwoConnectors_ReturnsSingleSlotCorridor +- **`Rendering-Layout-EdgeCountGapWidener-DegenerateCases`**: + Widen_SingleConnector_ReturnsBaseGap, Widen_ZeroConnectors_ReturnsBaseGap +- **`Rendering-Layout-EdgeCountGapWidener-NeverNarrows`**: + Widen_BaseGapExceedsCorridor_ReturnsBaseGap diff --git a/docs/verification/rendering-layout/engine/engine.md b/docs/verification/rendering-layout/engine/engine.md index 111d30e..31a4dbf 100644 --- a/docs/verification/rendering-layout/engine/engine.md +++ b/docs/verification/rendering-layout/engine/engine.md @@ -9,15 +9,18 @@ Unit scenarios live in the Engine unit verification documents: - ContainmentPacker Unit Verification - InterconnectionLayoutEngine Unit Verification - Layered Pipeline Unit Verification +- LayoutTreePacker Unit Verification +- EdgeCountGapWidener Unit Verification #### Verification Approach The Engine subsystem is verified through its unit-level xUnit tests: each engine -(`OrthogonalEdgeRouter`, `ContainmentPacker`, `InterconnectionLayoutEngine`, `LayeredPipeline`) is -exercised directly on real inputs, and the assembled layered pipeline is byte-compared to a legacy -oracle. Because the engines have no shared runtime state, the subsystem does not require additional -integration mocks at its boundary — the unit tests together constitute the subsystem verification. -See each linked Unit Verification for engine-specific approach and mocking notes. +(`OrthogonalEdgeRouter`, `ContainmentPacker`, `InterconnectionLayoutEngine`, `LayeredPipeline`, +`LayoutTreePacker`, `EdgeCountGapWidener`) is exercised directly on real inputs, and the assembled +layered pipeline is byte-compared to a legacy oracle. Because the engines have no shared runtime +state, the subsystem does not require additional integration mocks at its boundary — the unit +tests together constitute the subsystem verification. See each linked Unit Verification for +engine-specific approach and mocking notes. #### Test Environment diff --git a/docs/verification/rendering-layout/engine/layered-pipeline.md b/docs/verification/rendering-layout/engine/layered-pipeline.md index 7205622..65c88ca 100644 --- a/docs/verification/rendering-layout/engine/layered-pipeline.md +++ b/docs/verification/rendering-layout/engine/layered-pipeline.md @@ -179,6 +179,18 @@ failure. triangle components, each with a short cycle producing a long back edge, and confirms the routed back-edge corridor reflects the parent graph's configured `BackEdgeEntryApproach` in every packed component instead of always reverting to the class default. +- **Component packing wired into Place** (`Rendering-Layout-LayeredPipeline-ComponentPackingWiredIntoPlace`): + `Place_DisconnectedComponents_PacksEachComponentSeparately` confirms `InterconnectionLayoutEngine.Place` + itself (with no caller-added stage) splits a disconnected graph into its connected components, lays + each out independently, and packs them into non-overlapping bounding boxes. +- **Component packing composes safely inside a pipeline** + (`Rendering-Layout-LayeredPipeline-ComponentPackerIdempotentNormalization`): + `ComponentPacker_Apply_ComposedInDownPipeline_DoesNotDoubleSwapAxes` confirms that composing + `ComponentPacker` as an inner stage of a `LayeredLayoutPipeline` for a Down-direction graph does not + double-swap the input node axes: `AxisTransform.NormalizeInputAxes` is idempotent + (`LayeredGraph.InputAxesNormalized`), so the pipeline's own normalization call followed by + `ComponentPacker.Apply`'s internal call swaps the axes exactly once, matching the direct + `ComponentPacker.WithDefaultStages().Apply(graph)` call path. - **Shared state** (`Rendering-Layout-LayeredPipeline-SharedState`): `LayeredGraph_Constructor_ValidInput_StoresNodesEdgesDirectionAndCount`, `LayeredGraph_Constructor_NullNodes_ThrowsArgumentNullException`, and @@ -286,6 +298,10 @@ failure. ComponentPacker_Apply_MultiComponent_ParallelAndSelfEdges_MergesAlignedWaypoints - **`Rendering-Layout-LayeredPipeline-PackedComponentsBackEdgeApproach`**: ComponentPacker_Apply_MultiComponent_PropagatesBackEdgeEntryApproach +- **`Rendering-Layout-LayeredPipeline-ComponentPackingWiredIntoPlace`**: + Place_DisconnectedComponents_PacksEachComponentSeparately +- **`Rendering-Layout-LayeredPipeline-ComponentPackerIdempotentNormalization`**: + ComponentPacker_Apply_ComposedInDownPipeline_DoesNotDoubleSwapAxes - **`Rendering-Layout-LayeredPipeline-SharedState`**: LayeredGraph_Constructor_ValidInput_StoresNodesEdgesDirectionAndCount, LayeredGraph_Constructor_NullNodes_ThrowsArgumentNullException, diff --git a/docs/verification/rendering-layout/engine/layout-tree-packer.md b/docs/verification/rendering-layout/engine/layout-tree-packer.md new file mode 100644 index 0000000..0a62226 --- /dev/null +++ b/docs/verification/rendering-layout/engine/layout-tree-packer.md @@ -0,0 +1,77 @@ +### LayoutTreePacker Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the layout-tree-packer unit requirements to named test scenarios. + +#### Verification Approach + +`LayoutTreePacker` is a stateless static engine, so verification is by direct xUnit unit tests that +call `Pack` on synthetic `LayoutTree` lists. No mocks are used; the tests observe the real shelf- +packing and recursive-translation logic end-to-end so multi-tree packing, coordinate translation +(including nested children, waypoints, and port centres), degenerate cases, warning preservation, and +the unsupported-node-type guard are all measured on production output. + +#### Test Environment + +- **Framework**: xUnit v3 running on the .NET SDK. +- **Runner**: `dotnet test` invoked by `build.ps1` and the CI pipeline. +- **Project**: `test/DemaConsulting.Rendering.Layout.Tests/Engine/LayoutTreePackerTests.cs`. +- **Dependencies**: no external services, files, or network access; every test constructs its own + in-memory `LayoutTree` instances. +- **Isolation**: each test builds its own inputs; the engine holds no state between calls. + +#### Acceptance Criteria + +A verification run passes when every named scenario below asserts without unexpected exception, and +the referenced tests cover each `Rendering-Layout-LayoutTreePacker-*` requirement. Any overlap between +packed sub-trees, an untranslated coordinate (box, nested child, waypoint, or port centre), a wrong +degenerate-case result, a dropped warning, a silently-skipped unrecognized node type, or a missing +null-argument guard constitutes a failure. + +#### Test Scenarios + +- **Packs multiple trees** (`Rendering-Layout-LayoutTreePacker-PacksMultipleTrees`): + `Pack_TwoTrees_PlacesSecondBesideFirstWithoutOverlap` confirms two trees pack side by side without + overlapping; `Pack_ManyTrees_WrapsOntoNewShelfWithoutOverlap` confirms enough trees to overflow one + shelf wrap onto a new row without any pair overlapping. +- **Translates coordinates** (`Rendering-Layout-LayoutTreePacker-TranslatesCoordinates`): + `Pack_TwoTrees_PlacesSecondBesideFirstWithoutOverlap` confirms the second tree's box coordinates are + shifted by its assigned shelf offset. +- **Translates nested children** (`Rendering-Layout-LayoutTreePacker-TranslatesNestedChildren`): + `Pack_BoxWithNestedChildren_TranslatesChildrenRecursively` confirms a packed container box's nested + children are shifted by the same offset as their parent. +- **Translates waypoints** (`Rendering-Layout-LayoutTreePacker-TranslatesWaypoints`): + `Pack_TreeWithLine_TranslatesEveryWaypoint` confirms every waypoint of a packed connector line is + translated; `Pack_TreeWithPort_TranslatesCentre` confirms a packed port's centre coordinate is + translated. +- **Degenerate cases** (`Rendering-Layout-LayoutTreePacker-DegenerateCases`): + `Pack_EmptyList_ReturnsEmptyTree` confirms an empty list yields a zero-size, empty tree; + `Pack_SingleTree_ReturnsItUnchanged` confirms a single-tree list is returned unchanged. +- **Unsupported node type** (`Rendering-Layout-LayoutTreePacker-UnsupportedNodeType`): + `Pack_UnrecognizedNodeType_ThrowsNotSupportedException` confirms a sub-tree containing a `LayoutNode` + subtype outside the closed set throws `NotSupportedException` rather than being silently skipped. +- **Preserves warnings** (`Rendering-Layout-LayoutTreePacker-PreservesWarnings`): + `Pack_TreesWithWarnings_PreservesAllWarnings` confirms every packed sub-tree's `Warnings` entries + appear in the combined tree. +- **Validation** (`Rendering-Layout-LayoutTreePacker-Validation`): `Pack_NullTrees_ThrowsArgumentNullException` + confirms a null `trees` argument is rejected with an argument-null error. + +#### Requirements Coverage + +- **`Rendering-Layout-LayoutTreePacker-PacksMultipleTrees`**: + Pack_TwoTrees_PlacesSecondBesideFirstWithoutOverlap, Pack_ManyTrees_WrapsOntoNewShelfWithoutOverlap +- **`Rendering-Layout-LayoutTreePacker-TranslatesCoordinates`**: + Pack_TwoTrees_PlacesSecondBesideFirstWithoutOverlap +- **`Rendering-Layout-LayoutTreePacker-TranslatesNestedChildren`**: + Pack_BoxWithNestedChildren_TranslatesChildrenRecursively +- **`Rendering-Layout-LayoutTreePacker-TranslatesWaypoints`**: + Pack_TreeWithLine_TranslatesEveryWaypoint, Pack_TreeWithPort_TranslatesCentre +- **`Rendering-Layout-LayoutTreePacker-DegenerateCases`**: + Pack_EmptyList_ReturnsEmptyTree, Pack_SingleTree_ReturnsItUnchanged +- **`Rendering-Layout-LayoutTreePacker-UnsupportedNodeType`**: + Pack_UnrecognizedNodeType_ThrowsNotSupportedException +- **`Rendering-Layout-LayoutTreePacker-PreservesWarnings`**: + Pack_TreesWithWarnings_PreservesAllWarnings +- **`Rendering-Layout-LayoutTreePacker-Validation`**: + Pack_NullTrees_ThrowsArgumentNullException diff --git a/docs/verification/rendering-layout/engine/orthogonal-edge-router.md b/docs/verification/rendering-layout/engine/orthogonal-edge-router.md index 89c1032..524a428 100644 --- a/docs/verification/rendering-layout/engine/orthogonal-edge-router.md +++ b/docs/verification/rendering-layout/engine/orthogonal-edge-router.md @@ -60,6 +60,28 @@ supplied side, incorrect `Crossed` flag, or lost cost-band bias constitutes a fa alternate lane instead of riding a soft obstacle that occupies the natural corridor for an extended span; `RouteWithStatus_ShortSoftObstacleOverlap_KeepsStraightRoute` confirms a short, incidental overlap still stays cheaper than the detour and is tolerated as before. + `RouteWithStatus_SaturatedNarrowGapSoftObstacles_StaysWithinEnvelope` pins the envelope-departure + safeguard directly: with several soft obstacles pre-seeded at ever-increasing offsets simulating the + "stair-stepping" candidate lane a stack of already-routed parallel connectors produced before the fix, + the route stays within the source/target envelope instead of landing a lane behind the source box's + own footprint. +- **No connector-to-connector crossing** (`Rendering-Layout-OrthogonalEdgeRouter-AvoidsConnectorCrossing`): + `RouteWithStatus_VerticalOrientedSoftObstacleAcrossOneLRoute_PrefersNonCrossingAlternative` pins the + orientation-aware crossing cost directly: a single vertical-oriented soft obstacle is seeded so that + it crosses one of two otherwise equally-cheap single-turn "L" routes between source and target, + leaving the other entirely clear, and the router is asserted to take the non-crossing alternative. + `Route_NineParallelEdgesIntoCompartmentBox_DoNotLoopBehindEitherBox` (in + `ConnectorRouterTests.cs`) additionally asserts, via a new pairwise + `AssertNoTransversalCrossingAcrossLines` check across every routed connector's interior + segments, that a full batch of parallel connectors sharing a narrow inter-box gap does not cross one + another. In this saturated 9-edge scenario a residual crossing between one connector's own + endpoint-adjacent approach segment (excluded from the soft-obstacle set, matching + `ConnectorRouter.AddLineObstacles`' own endpoint-adjacent exclusion) and a later connector's interior + trunk can still occur — a routing-order limitation the interior-segment-only assertion does not + cover, and one no per-move cost constant can resolve, since the earlier connector's own soft + obstacles never include connectors routed after it. See + `docs/gallery/parallel-edges-and-ports/README.md`'s side-by-side gallery entry for the documented + visual disclosure of this residual case. #### Requirements Coverage @@ -79,4 +101,8 @@ supplied side, incorrect `Crossed` flag, or lost cost-band bias constitutes a fa - **`Rendering-Layout-OrthogonalEdgeRouter-NoWaypointRevisit`**: RouteWithStatus_SoftObstacleDetour_DoesNotRevisitWaypoint - **`Rendering-Layout-OrthogonalEdgeRouter-AvoidsExtendedSoftOverlap`**: - RouteWithStatus_LongSoftObstacleOverlap_PrefersAlternateLane, RouteWithStatus_ShortSoftObstacleOverlap_KeepsStraightRoute + RouteWithStatus_LongSoftObstacleOverlap_PrefersAlternateLane, RouteWithStatus_ShortSoftObstacleOverlap_KeepsStraightRoute, + RouteWithStatus_SaturatedNarrowGapSoftObstacles_StaysWithinEnvelope +- **`Rendering-Layout-OrthogonalEdgeRouter-AvoidsConnectorCrossing`**: + RouteWithStatus_VerticalOrientedSoftObstacleAcrossOneLRoute_PrefersNonCrossingAlternative, + Route_NineParallelEdgesIntoCompartmentBox_DoNotLoopBehindEitherBox diff --git a/docs/verification/rendering-layout/hierarchical-layout-algorithm.md b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md index 24b7cb7..167696b 100644 --- a/docs/verification/rendering-layout/hierarchical-layout-algorithm.md +++ b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md @@ -79,6 +79,8 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a third-level leaf chain. `Apply_ThreeLevelDirectionCascade_MidLevelOverrideTakesPrecedence` confirms a deeper, explicit override wins over an inherited ancestor value rather than the ancestor's value winning because it was set first or higher in the tree. +- **Cascades to every CoreOptions property** + (`Rendering-Layout-HierarchicalLayout-CascadesAllProperties`): `Apply_ThreeLevelEdgeRoutingCascade_ReachesEveryLeafAlgorithmCall` uses a recording leaf-algorithm test double to confirm the cascaded effective options snapshot — including `CoreOptions.EdgeRouting` and an arbitrary custom marker property proving generality — actually reaches every leaf-algorithm @@ -102,8 +104,10 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a container's boundary port resolves to exactly one `LayoutPort` anchor carrying both the external and internal labels on the container boundary, with both the external approach edge and the internal delegation connector reaching that one anchor. +- **Boundary port fan consolidation** (`Rendering-Layout-HierarchicalLayout-BoundaryPortFanMerge`): `Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor` confirms external and internal fan-out (two - approach edges and two delegation edges) still consolidate onto one shared anchor, and + approach edges and two delegation edges) still consolidate onto one shared anchor. +- **Independent boundary ports** (`Rendering-Layout-HierarchicalLayout-IndependentBoundaryPorts`): `Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors` confirms two independent boundary ports on one container each resolve to their own shared anchor. `Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_PreservesConnectorProvenance` and @@ -112,6 +116,7 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a traced back to its own true external sibling and its internal connector to its own true child, with no cross-wiring, in the case (a shared or both-null `ExternalLabel`) that previously caused label-string matching to silently mis-associate the two ports' anchors. +- **Boundary port orthogonal routing** (`Rendering-Layout-HierarchicalLayout-BoundaryPortOrthogonal`): Full-waypoint-shape regression is asserted by `MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal` and `MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal`, which @@ -121,6 +126,22 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a extends this to a three-level delegation chain, confirming the whole chain reads as one connected, strictly orthogonal path from the outermost sibling down to the innermost leaf across both boundary crossings. +- **Sibling-container gap widening** (`Rendering-Layout-HierarchicalLayout-SiblingContainerGapWidening`): + `Apply_SiblingContainersWithEightCrossEdges_WidensGapToCorridorWidth` confirms two side-by-side peer + containers joined by eight cross-container edges are spread apart to the shared connector-corridor + width (so the parallel fan gets distinct routing lanes). +- **Sibling-container gap widening leaves single-edge pairs unaffected** + (`Rendering-Layout-HierarchicalLayout-SiblingContainerGapWideningSingleEdgeUnaffected`): + `Apply_SiblingContainersWithSingleCrossEdge_LeavesGapUnwidened` confirms a pair joined by a single + cross-container edge keeps the unwidened baseline gap. +- **Sibling-container gap widening leaves the boundary-port path unaffected** + (`Rendering-Layout-HierarchicalLayout-SiblingGapWideningPortPathUnaffected`): + `HierarchicalLayoutAlgorithm_NoBoundaryPortHierarchy_OutputIsByteIdenticalBeforeAndAfterChange` + (the existing golden regression, exercising the no-boundary-port path with one cross-container edge) + continues to pass byte-identical, proving the pass is inert when no pair qualifies, and + `Apply_BoundaryPortWithParallelApproaches_ResolvesToOneSharedAnchorUnaffectedByWidening` confirms the + boundary-port path (where the widening is deliberately deferred) resolves its parallel approaches to + one shared anchor unaffected by the new pass. - **Boundary port edge throws** (`Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows`): `Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws` confirms a port owned by a plain (non-container) node with an edge straight into a different container's nested child — a @@ -149,10 +170,18 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a HierarchicalLayoutAlgorithm_NoBoundaryPortHierarchy_OutputIsByteIdenticalBeforeAndAfterChange - **`Rendering-Layout-HierarchicalLayout-CrossContainerEdge`**: Apply_CrossContainerEdge_RoutesAroundInterveningContainer +- **`Rendering-Layout-HierarchicalLayout-SiblingContainerGapWidening`**: + Apply_SiblingContainersWithEightCrossEdges_WidensGapToCorridorWidth +- **`Rendering-Layout-HierarchicalLayout-SiblingContainerGapWideningSingleEdgeUnaffected`**: + Apply_SiblingContainersWithSingleCrossEdge_LeavesGapUnwidened +- **`Rendering-Layout-HierarchicalLayout-SiblingGapWideningPortPathUnaffected`**: + Apply_BoundaryPortWithParallelApproaches_ResolvesToOneSharedAnchorUnaffectedByWidening, + HierarchicalLayoutAlgorithm_NoBoundaryPortHierarchy_OutputIsByteIdenticalBeforeAndAfterChange - **`Rendering-Layout-HierarchicalLayout-CascadesOptions`**: Apply_ContainerWithDirectionOverride_HonorsNestedDirection, Apply_ThreeLevelDirectionCascade_InheritsThroughUnsetMiddleLevel, - Apply_ThreeLevelDirectionCascade_MidLevelOverrideTakesPrecedence, + Apply_ThreeLevelDirectionCascade_MidLevelOverrideTakesPrecedence +- **`Rendering-Layout-HierarchicalLayout-CascadesAllProperties`**: Apply_ThreeLevelEdgeRoutingCascade_ReachesEveryLeafAlgorithmCall - **`Rendering-Layout-HierarchicalLayout-HonorsScopeEdgeRouting`**: Apply_CrossContainerEdge_HonorsScopeEdgeRoutingOverride @@ -160,11 +189,14 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a Apply_SameScopePortEdge_WithUnrelatedContainerElsewhere_RoutesLikePortEdge, Apply_NestedContainerPortEdge_TranslatesPortIntoAncestorCoordinates - **`Rendering-Layout-HierarchicalLayout-BoundaryPortDelegation`**: - Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels, - Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor, + Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels +- **`Rendering-Layout-HierarchicalLayout-BoundaryPortFanMerge`**: + Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor +- **`Rendering-Layout-HierarchicalLayout-IndependentBoundaryPorts`**: Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors, Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_PreservesConnectorProvenance, - Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance, + Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance +- **`Rendering-Layout-HierarchicalLayout-BoundaryPortOrthogonal`**: MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal, MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal, HierarchicalLayoutAlgorithm_ThreeLevelDelegationChain_EndToEnd_ProducesConnectedOrthogonalPath diff --git a/gallery.ps1 b/gallery.ps1 index b3c43ae..17c5584 100644 --- a/gallery.ps1 +++ b/gallery.ps1 @@ -8,8 +8,10 @@ # On a normal build (build.ps1 / CI) those facts write to a throwaway directory # 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/README.md index. +# project, so the same facts (re)write the committed SVG/PNG images into their +# per-topology group subfolders (for example docs/gallery/flow-pipeline/) and +# regenerate the browsable docs/gallery/README.md top-level index plus each +# group's own docs/gallery//README.md page. # # USAGE: # pwsh ./gallery.ps1 diff --git a/requirements.yaml b/requirements.yaml index 6f227f0..35de555 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -29,6 +29,9 @@ includes: - docs/reqstream/rendering-layout/containment-layout.yaml - docs/reqstream/rendering-layout/containment-layout-algorithm.yaml - docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml + - docs/reqstream/rendering-layout/auto-layout-algorithm.yaml + - docs/reqstream/rendering-layout/engine/layout-tree-packer.yaml + - docs/reqstream/rendering-layout/engine/edge-count-gap-widener.yaml - docs/reqstream/rendering-layout/default-layout.yaml - docs/reqstream/rendering-svg.yaml - docs/reqstream/rendering-svg/svg-renderer.yaml @@ -44,6 +47,7 @@ includes: - docs/reqstream/ots/versionmark.yaml - docs/reqstream/ots/sarifmark.yaml - docs/reqstream/ots/sonarmark.yaml + - docs/reqstream/ots/sonarscanner.yaml - docs/reqstream/ots/reviewmark.yaml - docs/reqstream/ots/pandoc.yaml - docs/reqstream/ots/weasyprint.yaml diff --git a/src/DemaConsulting.Rendering.Layout/AutoLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/AutoLayoutAlgorithm.cs new file mode 100644 index 0000000..2250eb8 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/AutoLayoutAlgorithm.cs @@ -0,0 +1,460 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout; + +/// +/// The bundled auto-routing meta-algorithm: splits the input graph into its connected top-level +/// components, routes each component to whichever bundled leaf algorithm best suits its shape, lays +/// each component out independently, and packs the resulting placed sub-trees into one combined +/// with . +/// +/// +/// +/// Routing rule. Every top-level node is assigned to a connected component using +/// the graph's top-level edges (mirroring 's and +/// 's own node/port endpoint resolution — replicated here +/// rather than shared, since each caller's resolution serves a slightly different purpose and the +/// three implementations are independently small). Each component is then routed by shape: +/// +/// +/// +/// A component containing any node with is routed to +/// (which recurses further into any nesting on its +/// own), regardless of the component's size — a single isolated container node still needs the +/// hierarchical engine to lay out its children. +/// +/// +/// A component with two or more nodes and no children anywhere in it (including a single node +/// carrying only a self-loop edge) is routed to , since it +/// has genuine connectivity for the layered engine's Sugiyama layering to exploit. +/// +/// +/// A truly childless, edgeless singleton node — no children, no incident edges at all — carries +/// no connectivity or nesting information a layered or hierarchical layout could use, so every +/// such singleton across the whole graph is instead gathered into one shared bucket routed +/// through , which packs unrelated peer boxes into a +/// balanced block. +/// +/// +/// +/// Fast path: nothing to split. When the routing above produces exactly one group +/// overall (either a single non-singleton component, or every top-level node is a childless, +/// edgeless singleton), the graph is not split at all: it is delegated directly, unchanged, to that +/// one leaf algorithm's , so the result is byte-for-byte +/// identical to invoking that algorithm directly. This mirrors +/// 's own flat-graph equivalence guarantee, and is the +/// common case: a single fully (or mostly) connected diagram never pays any splitting or copying +/// cost. +/// +/// +/// Sub-graph construction copies, it cannot reuse, node instances. When more than +/// one group is produced, each group is laid out on its own freshly-built +/// rather than a shared view of the original: the public / +/// API offers no way to insert an existing node instance into a +/// different graph's node list, and has no setter, so an +/// original node cannot simply be attached, by reference, to a new parent graph. Every node in a +/// split-off component is therefore copied field-by-field — , +/// , , +/// , , +/// , , +/// , its named ports, and (recursively) its entire +/// nested subgraph — with edges re-added afterward once every +/// node and port in the component has a copy, so both direct and cross-container edge endpoints +/// resolve correctly regardless of nesting depth. +/// +/// +/// Known, disclosed limitation. A node's or edge's own arbitrary +/// option overrides (set with node.Set(property, value), for +/// example a per-node override on a container) are +/// not copied onto a split component's nodes: exposes no +/// generic API to enumerate or copy an arbitrary set of overrides onto a different instance, and +/// adding one purely to support this rarely-hit multi-component path was judged out of scope. The +/// graph-level overrides that matter most in practice are unaffected: the original graph's own +/// cascaded (direction, spacing, edge routing, and so on) are captured +/// once, before splitting, via and passed as the fallback +/// options to every split component's leaf algorithm, so a graph-level override still applies to +/// every piece exactly as it would have applied to the whole. Only a node- or edge-level override, +/// specifically on a node that ends up copied into a split component, would be silently dropped — +/// an edge case a caller can avoid today by preferring graph-level (or per-scope +/// -level) overrides over per-node ones when also relying on +/// "auto" routing. +/// +/// +/// +/// +/// // A connected cluster plus two unrelated singleton boxes: the cluster routes to "layered", and +/// // the two singletons are packed together via "containment". +/// var graph = new LayoutGraph(); +/// var a = graph.AddNode("a", 80, 40); +/// var b = graph.AddNode("b", 80, 40); +/// graph.AddEdge("a-b", a, b); +/// graph.AddNode("solo1", 80, 40); +/// graph.AddNode("solo2", 80, 40); +/// +/// var tree = new AutoLayoutAlgorithm().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 AutoLayoutAlgorithm : LayoutAlgorithmBase +{ + /// + /// The stable algorithm identifier "auto" under which this algorithm is selected and + /// registered. Pass it to or + /// instead of hardcoding the literal string. + /// + public const string AlgorithmId = "auto"; + + /// + /// Gap, in logical pixels, kept between packed components by . Matches + /// 's own inter-box spacing so a graph mixing routed + /// components and the singleton bucket reads as one consistently-spaced canvas. + /// + private const double ComponentSpacing = 24.0; + + /// + /// Target width-to-height multiplier aims for when arranging + /// components onto shelves. Matches 's own landscape bias. + /// + private const double ComponentAspectRatio = 4.0 / 3.0; + + private readonly HierarchicalLayoutAlgorithm _hierarchical = new(); + private readonly LayeredLayoutAlgorithm _layered = new(); + private readonly ContainmentLayoutAlgorithm _containment = new(); + + /// + public override string Id => AlgorithmId; + + /// + protected internal override LayoutTree ApplyCore(LayoutGraph graph, LayoutOptions options) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(options); + + var nodes = graph.Nodes; + var count = nodes.Count; + if (count == 0) + { + // No top-level nodes: nothing to route or split. Hierarchical is a pure pass-through to its + // default leaf (layered) when no container is present, so this is equivalent to routing an + // empty graph through any bundled algorithm. + return _hierarchical.ApplyCore(graph, options); + } + + var indexOf = new Dictionary(count); + var portOwnerIndex = new Dictionary(); + for (var i = 0; i < count; i++) + { + var node = nodes[i]; + indexOf[node] = i; + if (!node.HasPorts) + { + continue; + } + + foreach (var port in node.Ports.Ports) + { + portOwnerIndex[port] = i; + } + } + + // Union-find over top-level nodes, connected by every top-level edge whose endpoints both + // resolve to a top-level node or one of its own ports (an edge referencing a node outside this + // scope, or a deeply-nested descendant referenced directly rather than through its top-level + // container, is not considered for connectivity — mirroring the "drop unresolvable endpoints" + // convention every bundled leaf algorithm already follows). + var parent = new int[count]; + for (var i = 0; i < count; i++) + { + parent[i] = i; + } + + var selfLoop = new bool[count]; + foreach (var edge in graph.Edges) + { + if (!TryResolveOwner(edge.Source, indexOf, portOwnerIndex, out var s) || + !TryResolveOwner(edge.Target, indexOf, portOwnerIndex, out var t)) + { + continue; + } + + if (s == t) + { + selfLoop[s] = true; + } + else + { + Union(parent, s, t); + } + } + + // Group node indices by their component root, preserving first-appearance order so the packed + // output is deterministic. + var componentOrder = new List(); + var componentMembers = new Dictionary>(); + for (var i = 0; i < count; i++) + { + var root = Find(parent, i); + if (!componentMembers.TryGetValue(root, out var members)) + { + members = []; + componentMembers[root] = members; + componentOrder.Add(root); + } + + members.Add(i); + } + + // Classify each component: any node with children routes the whole component to hierarchical; + // otherwise two-or-more members (or a lone node with a self-loop) route to layered; a lone, + // childless, edgeless node is deferred into the shared singleton bucket. + var routedGroups = new List<(List Members, LayoutAlgorithmBase Algorithm)>(); + var singletons = new List(); + foreach (var root in componentOrder) + { + var members = componentMembers[root]; + var anyChildren = false; + foreach (var member in members) + { + if (nodes[member].HasChildren) + { + anyChildren = true; + break; + } + } + + if (anyChildren) + { + routedGroups.Add((members, _hierarchical)); + } + else if (members.Count > 1 || selfLoop[members[0]]) + { + routedGroups.Add((members, _layered)); + } + else + { + singletons.Add(members[0]); + } + } + + // Fast path: exactly one group overall means nothing needs to be split — delegate straight to + // that group's algorithm on the original, unmodified graph. + if (routedGroups.Count == 1 && singletons.Count == 0) + { + return routedGroups[0].Algorithm.ApplyCore(graph, options); + } + + if (routedGroups.Count == 0 && singletons.Count == count) + { + return _containment.ApplyCore(graph, options); + } + + // Genuine multi-group case: capture the graph's own cascaded options once (so a graph-level + // override still applies to every split-off piece), split each group into its own freshly-built + // sub-graph, lay each out independently, and pack the results into one combined tree. + // + // The captured options must not keep carrying this graph's own CoreOptions.Algorithm value + // (typically "auto" itself, since that is how a caller selected this algorithm in the first + // place): each split-off group's leaf/hierarchical algorithm was already chosen by the routing + // rule above, but HierarchicalLayoutAlgorithm re-reads CoreOptions.Algorithm from its own + // effective options to resolve ITS OWN top scope's leaf algorithm, and "auto" is never a + // registered leaf identifier there. Resetting it to the layered default (the same default + // HierarchicalLayoutAlgorithm itself falls back to when nothing declares an override) restores + // the cascade to exactly what an ordinary caller not using "auto" would see. + var effective = graph.OverlayOnto(options); + effective.Set(CoreOptions.Algorithm, LayeredLayoutAlgorithm.AlgorithmId); + + var trees = new List(routedGroups.Count + (singletons.Count > 0 ? 1 : 0)); + foreach (var (members, algorithm) in routedGroups) + { + var subGraph = BuildComponentGraph(graph, members); + trees.Add(algorithm.ApplyCore(subGraph, effective)); + } + + if (singletons.Count > 0) + { + var subGraph = BuildComponentGraph(graph, singletons); + trees.Add(_containment.ApplyCore(subGraph, effective)); + } + + return LayoutTreePacker.Pack(trees, ComponentSpacing, ComponentAspectRatio); + } + + /// + /// Resolves a connectable edge endpoint to the top-level node index it belongs to: itself for a + /// top-level node, or its owning node's index for one of that node's own ports. + /// + private static bool TryResolveOwner( + ILayoutConnectable connectable, + Dictionary indexOf, + Dictionary portOwnerIndex, + out int index) + { + switch (connectable) + { + case LayoutGraphNode node when indexOf.TryGetValue(node, out index): + return true; + + case LayoutGraphPort port when portOwnerIndex.TryGetValue(port, out index): + return true; + + default: + index = 0; + return false; + } + } + + /// Finds the representative root of 's set, with path halving. + private static int Find(int[] parent, int x) + { + while (parent[x] != x) + { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + + return x; + } + + /// Merges the sets containing and . + private static void Union(int[] parent, int a, int b) + { + var rootA = Find(parent, a); + var rootB = Find(parent, b); + if (rootA != rootB) + { + parent[rootA] = rootB; + } + } + + /// + /// Builds a new, self-contained holding a deep copy of every node listed + /// in (recursively including nested children and ports), plus a + /// copy of every edge from (at every nesting depth reachable from those + /// members) whose endpoints both resolve inside the copy. + /// + /// The graph the component's nodes are copied out of. + /// Top-level node indices, into 's + /// , belonging to this component. + /// The newly-built sub-graph for the component. + private static LayoutGraph BuildComponentGraph(LayoutGraph original, List memberIndices) + { + var target = new LayoutGraph(); + var nodeMap = new Dictionary(); + var portMap = new Dictionary(); + var pendingEdges = new List<(LayoutGraphEdge Edge, LayoutGraph Container)>(); + + var originalNodes = original.Nodes; + foreach (var index in memberIndices) + { + CopyNode(originalNodes[index], target, nodeMap, portMap, pendingEdges); + } + + // Root-level edges belong to the copy's own root scope, mirroring the original's own scoping. + foreach (var edge in original.Edges) + { + pendingEdges.Add((edge, target)); + } + + // Every node and port in the component (at every nesting depth) now has a copy, so cross-scope + // edge endpoints resolve regardless of how deep either endpoint is nested. + foreach (var (edge, container) in pendingEdges) + { + if (!TryMapConnectable(edge.Source, nodeMap, portMap, out var mappedSource) || + !TryMapConnectable(edge.Target, nodeMap, portMap, out var mappedTarget)) + { + // Endpoint outside this component (or otherwise unresolvable) — dropped, mirroring the + // "skip out-of-scope endpoints" convention every bundled leaf algorithm already follows. + continue; + } + + var copy = container.AddEdge(edge.Id, mappedSource, mappedTarget); + copy.TargetEnd = edge.TargetEnd; + copy.LineStyle = edge.LineStyle; + copy.Label = edge.Label; + } + + return target; + } + + /// + /// Deep-copies a single node (its own fields, its ports, and — recursively — its nested + /// subgraph) into , recording the + /// copy in / and queuing every nested-scope + /// edge it owns into for resolution once the whole component has + /// been copied. + /// + private static void CopyNode( + LayoutGraphNode source, + LayoutGraph target, + Dictionary nodeMap, + Dictionary portMap, + List<(LayoutGraphEdge Edge, LayoutGraph Container)> pendingEdges) + { + var copy = target.AddNode(source.Id, source.Width, source.Height); + copy.Label = source.Label; + copy.Shape = source.Shape; + copy.Keyword = source.Keyword; + copy.Compartments = source.Compartments; + copy.TitleHeight = source.TitleHeight; + copy.RoundedCornerRadius = source.RoundedCornerRadius; + copy.FolderTabWidth = source.FolderTabWidth; + copy.FolderTabHeight = source.FolderTabHeight; + nodeMap[source] = copy; + + if (source.HasPorts) + { + foreach (var port in source.Ports.Ports) + { + var portCopy = copy.Ports.AddPort(port.Id); + portCopy.ExternalLabel = port.ExternalLabel; + portCopy.InternalLabel = port.InternalLabel; + portMap[port] = portCopy; + } + } + + if (!source.HasChildren) + { + return; + } + + foreach (var child in source.Children.Nodes) + { + CopyNode(child, copy.Children, nodeMap, portMap, pendingEdges); + } + + foreach (var edge in source.Children.Edges) + { + pendingEdges.Add((edge, copy.Children)); + } + } + + /// Resolves a copied edge endpoint through /. + private static bool TryMapConnectable( + ILayoutConnectable connectable, + Dictionary nodeMap, + Dictionary portMap, + out ILayoutConnectable mapped) + { + switch (connectable) + { + case LayoutGraphNode node when nodeMap.TryGetValue(node, out var mappedNode): + mapped = mappedNode; + return true; + + case LayoutGraphPort port when portMap.TryGetValue(port, out var mappedPort): + mapped = mappedPort; + return true; + + default: + mapped = null!; + return false; + } + } +} diff --git a/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs b/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs index baa0131..337cb74 100644 --- a/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs +++ b/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs @@ -12,12 +12,16 @@ namespace DemaConsulting.Rendering.Layout; /// carried onto the resulting . /// /// -/// The source box. Its instance identity is used to exclude it from the obstacle set while routing, -/// so pass the exact reference that appears in the box list. +/// The source box, used to resolve the connector's departure anchor and face; pass the exact +/// reference that appears in the box list. It still counts as a hard obstacle +/// for the rest of the route — the router steps the anchor off this box's face before obstacle +/// avoidance begins, so the connector can still leave cleanly. /// /// -/// The target box. Its instance identity is used to exclude it from the obstacle set while routing; -/// the connector's target end marker is drawn where the route meets this box. +/// The target box, used to resolve the connector's arrival anchor and face; the connector's target end +/// marker is drawn where the route meets this box. It still counts as a hard obstacle for the rest of +/// the route — the router steps the anchor off this box's face before obstacle avoidance begins, so the +/// connector can still arrive cleanly. /// /// End-marker style drawn at the target (arrival) end of the connector. /// Stroke style (solid, dashed, dotted) applied to the routed connector. diff --git a/src/DemaConsulting.Rendering.Layout/ContainmentLayout.cs b/src/DemaConsulting.Rendering.Layout/ContainmentLayout.cs index c19886d..76a5019 100644 --- a/src/DemaConsulting.Rendering.Layout/ContainmentLayout.cs +++ b/src/DemaConsulting.Rendering.Layout/ContainmentLayout.cs @@ -26,6 +26,17 @@ namespace DemaConsulting.Rendering.Layout; /// Uniform padding, in logical pixels, added around the entire packed region on every side. Defaults to /// 12.0. Corresponds to ELK's padding inset between a container's border and its contents. /// +/// +/// Optional per-pair edge counts used to widen the horizontal gap between two adjacent same-row children +/// so a fan of that many parallel connectors routed between them has room to spread into distinct lanes. +/// Keyed by the unordered index pair (First, Second) with First < Second referencing +/// positions in the children list passed to ; only the gap +/// between children that end up in the same row is ever widened, and the widening only ever increases a +/// gap. Defaults to , which is fully backward compatible: with no lookup the packed +/// geometry is byte-identical to the historical output, and the row-wrap decision always uses the +/// un-widened regardless. Widening is deliberately confined to the horizontal +/// (same-row) axis; the vertical gap between rows is never widened. +/// /// /// The gap and padding defaults are deliberately modest, sensible values that read well for typical box /// sizes; supply explicit values to reproduce a specific container spacing (for example a downstream @@ -35,7 +46,8 @@ public sealed record ContainmentOptions( double MaxContentWidth, double HorizontalGap = 8.0, double VerticalGap = 8.0, - double Padding = 12.0); + double Padding = 12.0, + IReadOnlyDictionary<(int First, int Second), int>? EdgeCounts = null); /// /// The result of a containment-packing operation: the packed region size together with the input boxes @@ -142,7 +154,8 @@ public static ContainmentResult Pack( options.MaxContentWidth, options.HorizontalGap, options.VerticalGap, - options.Padding); + options.Padding, + options.EdgeCounts); // Reposition each child to its packed rectangle, preserving every non-position field. var placed = new LayoutBox[children.Count]; diff --git a/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs index fce9280..6ca870a 100644 --- a/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs @@ -89,6 +89,26 @@ public sealed class ContainmentLayoutAlgorithm : LayoutAlgorithmBase /// private const double NodeSpacing = 24.0; + /// + /// Minimum number of boxes before the column-count-based width candidate (see + /// ) is considered at all. A handful of boxes with very different + /// sizes (for example two boxes, one much taller than the other) legitimately want to stack in a + /// single column rather than be forced side by side just because ceil(sqrt(n)) is greater + /// than one; the column estimate only pays for itself once there are enough boxes for multi-column + /// packing to plausibly make sense. + /// + private const int MinBoxesForColumnEstimate = 6; + + /// + /// Maximum allowed ratio between the largest and smallest box width (and separately height) for the + /// column-count-based width candidate to apply. The motivating scenario for that candidate is many + /// boxes that are each individually wide-but-short and roughly uniform in size (for example a row of + /// small labelled tiles); when box sizes vary widely — as with a couple of very differently sized + /// boxes — the average width used by the estimate is not representative of any single box, so the + /// candidate is skipped rather than risk widening the budget for a shape it was not designed for. + /// + private const double MaxColumnEstimateSizeRatio = 2.0; + /// public override string Id => AlgorithmId; @@ -126,12 +146,33 @@ protected internal override LayoutTree ApplyCore(LayoutGraph graph, LayoutOption CenterTitle: node.Compartments.Count == 0); } + // Build the per-pair edge-count lookup the packer uses to widen the horizontal gap between two + // same-row boxes carrying a fan of parallel connectors. Reuse the same indexOf map built for the + // boxes: for every edge whose source and target are both top-level nodes, increment the count for + // the unordered index pair (min, max). Edges referencing out-of-graph endpoints are ignored, the + // same endpoints the connection-building loop below skips. + var edgeCounts = new Dictionary<(int First, int Second), int>(); + foreach (var edge in graph.Edges) + { + if (edge.Source is LayoutGraphNode sourceNode && indexOf.TryGetValue(sourceNode, out var s) && + edge.Target is LayoutGraphNode targetNode && indexOf.TryGetValue(targetNode, out var t) && + s != t) + { + var key = s < t ? (s, t) : (t, s); + edgeCounts.TryGetValue(key, out var existing); + edgeCounts[key] = existing + 1; + } + } + // Pack the leaf boxes into rows within a width budget derived from a roughly 4:3 canvas, keeping - // a connector-aware gap between boxes so edges can route around intervening ones. + // a connector-aware gap between boxes so edges can route around intervening ones. The edge-count + // lookup additionally widens the horizontal gap between two same-row boxes joined by a fan of + // parallel connectors so those connectors get distinct routing lanes. var containmentOptions = new ContainmentOptions( ComputeContentWidth(boxes), HorizontalGap: NodeSpacing, - VerticalGap: NodeSpacing); + VerticalGap: NodeSpacing, + EdgeCounts: edgeCounts); var result = ContainmentLayout.Pack(boxes, containmentOptions); var packedBoxes = result.Children; @@ -188,20 +229,65 @@ private static EdgeRouting ResolveEdgeRouting(LayoutGraph graph, LayoutOptions o /// /// Derives the packer's content-width budget from the boxes: the square root of their total area - /// biased toward a landscape canvas, widened to at least the widest box, and floored to a positive - /// value so the packer always receives a usable width. + /// biased toward a landscape canvas, widened to at least the widest box and to a column-count-based + /// estimate for many small boxes, and floored to a positive value so the packer always receives a + /// usable width. /// + /// + /// The area-based target width alone under-estimates the right budget for a set of many + /// small, wide boxes: sqrt(totalArea * aspect) grows with the square root of the box + /// count, while a set of n equally-sized boxes actually wants roughly + /// sqrt(n) columns side by side — the same growth rate, but scaled by each box's own + /// width rather than by the square root of its area, which for a wide, short box (for example + /// 160 by 40) is a materially larger number. Without this second candidate, many small wide + /// boxes could be packed into an unrealistically narrow single- or two-column budget instead of + /// the balanced, roughly-square block of columns the 4:3 landscape bias intends. This candidate + /// is strictly additive — combined via Math.Max, so it only ever widens, never narrows, + /// the chosen budget. It is only considered when there are at least + /// boxes that are reasonably uniform in width and height + /// (see ): a handful of very differently sized boxes — + /// for example two boxes where one is much taller than the other — legitimately wants to stack + /// in a single column, and neither the box count nor the averaged width used by the estimate is + /// representative of that shape, so the candidate is skipped and the area-based target + /// (widened only to the widest box) governs instead. + /// private static double ComputeContentWidth(IReadOnlyList boxes) { var totalArea = 0.0; + var totalWidth = 0.0; var widest = 0.0; + var minWidth = double.PositiveInfinity; + var minHeight = double.PositiveInfinity; + var maxHeight = 0.0; foreach (var box in boxes) { totalArea += box.Width * box.Height; + totalWidth += box.Width; widest = Math.Max(widest, box.Width); + minWidth = Math.Min(minWidth, box.Width); + minHeight = Math.Min(minHeight, box.Height); + maxHeight = Math.Max(maxHeight, box.Height); } var target = Math.Sqrt(totalArea * CanvasAspectRatio); - return Math.Max(Math.Max(widest, target), MinContentWidth); + + // A column-count estimate (columns = ceil(sqrt(n))) sized from each box's own average width, + // rather than the total area, so a set of many small but wide boxes still wraps into a + // balanced grid of columns instead of one narrow column. Only considered when there are enough + // boxes for multi-column packing to plausibly make sense, and when the boxes are reasonably + // uniform in size — otherwise the average width is not representative of any single box, and a + // small handful of very differently sized boxes (for example one much taller than the other) + // should be free to stack in a single column instead. + var columnBasedWidth = 0.0; + if (boxes.Count >= MinBoxesForColumnEstimate && + widest / minWidth <= MaxColumnEstimateSizeRatio && + maxHeight / minHeight <= MaxColumnEstimateSizeRatio) + { + var columns = (int)Math.Ceiling(Math.Sqrt(boxes.Count)); + var averageWidth = totalWidth / boxes.Count; + columnBasedWidth = (columns * averageWidth) + ((columns - 1) * NodeSpacing); + } + + return Math.Max(Math.Max(Math.Max(widest, target), columnBasedWidth), MinContentWidth); } } diff --git a/src/DemaConsulting.Rendering.Layout/Engine/ContainmentPacker.cs b/src/DemaConsulting.Rendering.Layout/Engine/ContainmentPacker.cs index dd2eaff..ed584e2 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/ContainmentPacker.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/ContainmentPacker.cs @@ -41,7 +41,11 @@ internal sealed record PackResult(double Width, double Height, IReadOnlyList internal static class ContainmentPacker { @@ -56,13 +60,23 @@ internal static class ContainmentPacker /// Gap between adjacent items in the same row. /// Gap between adjacent rows. /// Uniform padding added around the entire packed region. + /// + /// Optional per-pair edge counts used to widen the horizontal gap between two adjacent same-row + /// items so a fan of that many parallel connectors has room to spread into distinct lanes. Keyed by + /// the unordered index pair (First, Second) with First < Second; a lookup for the + /// pair (i, i + 1) is consulted only when item i + 1 lands in the same row as + /// item i (no wrap occurred). Defaults to , in which case no gap is + /// widened and the output is byte-identical to a caller that supplied no counts — the wrap decision + /// itself always uses the un-widened so wrap points never move. + /// /// A describing item positions and the region size. public static PackResult Pack( IReadOnlyList items, double maxContentWidth, double horizontalGap, double verticalGap, - double padding) + double padding, + IReadOnlyDictionary<(int First, int Second), int>? edgeCounts = null) { ArgumentNullException.ThrowIfNull(items); @@ -85,7 +99,9 @@ public static PackResult Pack( var item = items[i]; // Determine whether this item starts a new row: it does not fit in the current row - // and the current row already has at least one item. + // and the current row already has at least one item. This test deliberately uses the + // un-widened cursor (cursorX carries only the base horizontalGap), so widening a gap never + // moves a wrap point relative to a caller that supplied no edge counts. var prospectiveRight = cursorX + item.Width; var contentRightLimit = padding + maxContentWidth; if (!isFirstInRow && prospectiveRight > contentRightLimit) @@ -96,6 +112,17 @@ public static PackResult Pack( rowTopY += rowHeight + verticalGap; cursorX = padding; rowHeight = 0.0; + isFirstInRow = true; + } + + // When this item stays in the current row (no wrap), its same-row predecessor is the item at + // index i - 1. If that pair carries a fan of parallel connectors, widen only this one + // horizontal gap so the connectors get distinct lanes. cursorX already carries the base + // horizontalGap past the predecessor, so add just the extra beyond that base. + if (!isFirstInRow && edgeCounts is not null && + edgeCounts.TryGetValue((i - 1, i), out var count) && count > 1) + { + cursorX += EdgeCountGapWidener.Widen(horizontalGap, count) - horizontalGap; } rects[i] = new PackedRect(cursorX, rowTopY, item.Width, item.Height); diff --git a/src/DemaConsulting.Rendering.Layout/Engine/EdgeCountGapWidener.cs b/src/DemaConsulting.Rendering.Layout/Engine/EdgeCountGapWidener.cs new file mode 100644 index 0000000..4d0aff2 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/EdgeCountGapWidener.cs @@ -0,0 +1,70 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Engine; + +/// +/// Widens a single node-to-node gap so a fan of parallel connectors routed through it has room to +/// spread into distinct orthogonal lanes instead of being crushed into one narrow channel. +/// +/// +/// This helper exists to give the containment packer and the hierarchical algorithm's +/// post-placement pass the same corridor-width math the layered pipeline's +/// BrandesKopfPlacer already uses between adjacent columns. Both call sites size the gap +/// between two boxes from the number of edges the connector router must fan through it, so +/// extracting the formula here keeps the three widening rules byte-identical and prevents the +/// copy-paste drift that would otherwise let one call site's spacing diverge from another's. The +/// method is a pure, stateless function of its two inputs and is safe for concurrent use. +/// +internal static class EdgeCountGapWidener +{ + /// + /// Widens to the corridor width a fan of + /// parallel connectors needs, never narrowing below the supplied base gap. + /// + /// + /// The corridor width mirrors ELK's routing-width formula (and this library's own + /// BrandesKopfPlacer corridor sizing): a clearance + /// () on each side of the fan plus one + /// slot-to-slot spacing () for every gap between + /// adjacent connectors. Combining that with via + /// makes the widening strictly additive: an edge count of zero or one leaves the base gap + /// unchanged (a single connector needs no extra lane), so every caller that supplies a low count + /// keeps its pre-existing spacing exactly. + /// + /// + /// The un-widened gap, in logical pixels, kept between the two boxes when no fan of connectors + /// needs extra room. Returned unchanged when it already meets or exceeds the corridor width. + /// + /// + /// The number of connectors routed through this gap. Values of zero or one never widen the gap; + /// each additional connector adds one of lane + /// width. Negative values are treated as zero by the formula (the subtraction floors at the base + /// gap through ). + /// + /// + /// The larger of and the corridor width required by + /// parallel connectors, in logical pixels. + /// + public static double Widen(double baseGap, int edgeCount) + { + // A single connector (or none) needs no extra lane at all: short-circuit before the corridor + // formula so the base gap is always returned unchanged, regardless of ConnectorClearance. The + // formula's own 2*ConnectorClearance term is not itself zero for edgeCount == 1, so leaving this + // case to Math.Max below would incorrectly widen any base gap narrower than that fixed + // clearance floor even though a lone connector needs no fan-out room whatsoever. + if (edgeCount <= 1) + { + return baseGap; + } + + // A fan of n connectors needs a clearance on each side plus one inter-connector spacing per gap + // between adjacent connectors (n - 1 gaps). Math.Max keeps the result at least the base gap. + var corridorWidth = + (2.0 * LayeredLayoutMetrics.ConnectorClearance) + ((edgeCount - 1) * LayeredLayoutMetrics.EdgeSpacing); + return Math.Max(baseGap, corridorWidth); + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs b/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs index 248d195..12d15f6 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs @@ -120,15 +120,19 @@ internal sealed record LayerResult( /// /// Thin façade over the reusable layered layout pipeline (see -/// ). -/// Assembles the default ELK-layered stage sequence and adapts its output to the -/// contract consumed by the interconnection view strategy. +/// , which wraps the +/// default ELK-layered stage sequence). Assembles the default stage sequence and adapts its output to +/// the contract consumed by the interconnection view strategy. /// /// /// All placement and routing logic lives in the individual pipeline stages under /// Layout/Engine/Layered/. This type exists only to preserve the original public entry -/// point and result shape; it is behavior-preserving with respect to the previous monolithic -/// implementation (verified byte for byte by the pipeline-equivalence tests). +/// point and result shape; for a single connected graph, it is behavior-preserving with respect to the +/// previous monolithic implementation (verified byte for byte by the pipeline-equivalence tests). Since +/// routes through +/// (see its class doc), a graph with 2+ disconnected components is now split, laid out per component, +/// and shelf-packed rather than stacked into a single column — this is a deliberate behavior change +/// from the historical single-column-for-everything output, not a regression. /// internal static class InterconnectionLayoutEngine { @@ -172,17 +176,49 @@ public static LayerResult Place( } var graph = new LayeredGraph(nodes, edges, direction) { NodeSpacing = nodeSpacing, MergeParallelEdges = mergeParallelEdges }; - var pipeline = LayeredLayoutPipeline.Builder() - .Direction(direction) - .Hierarchy(Layered.HierarchyHandling.Flat) - .AddDefaultStages() - .Build(); - pipeline.Run(graph); + ComponentPacker.WithDefaultStages(nodeSpacing).Apply(graph); var augX = graph.AugX; var augY = graph.AugY; var columnX = graph.ColumnX; var maxColWidth = graph.MaxColWidth; + var waypoints = graph.Waypoints; + + // ComponentPacker's single-component fast path runs the stages directly on this graph, which + // (like the original pipeline) bake in the canvas Padding as their own coordinate origin, so + // AugX/AugY/Waypoints already start at (Padding, Padding). Its multi-component path instead + // normalizes each packed component's content bounding box to touch the shelf origin (0, 0) — + // by design, since ComponentPacker is a reusable stage that should not assume any particular + // caller adds outer padding — so this façade must add that same (Padding, Padding) origin + // itself whenever the packed path ran (indicated by ColumnX being left empty; see its remarks). + if (columnX.Length == 0) + { + var shiftedX = new double[n]; + var shiftedY = new double[n]; + for (var i = 0; i < n; i++) + { + shiftedX[i] = augX[i] + Padding; + shiftedY[i] = augY[i] + Padding; + } + + augX = shiftedX; + augY = shiftedY; + + var shiftedWaypoints = new IReadOnlyList[waypoints.Count]; + for (var k = 0; k < waypoints.Count; k++) + { + var original = waypoints[k]; + var translated = new Point2D[original.Count]; + for (var p = 0; p < original.Count; p++) + { + translated[p] = new Point2D(original[p].X + Padding, original[p].Y + Padding); + } + + shiftedWaypoints[k] = translated; + } + + waypoints = shiftedWaypoints; + } // Assemble result. After the pipeline's AxisTransform stage the augmented coordinates are in // screen space, so each box is placed at its screen top-left with its intrinsic (un-swapped) @@ -198,9 +234,31 @@ public static LayerResult Place( // RIGHT/LEFT flow maps the along-axis to screen X and the cross-axis to screen Y; a DOWN/UP flow // transposes them. Computing both extents once and assigning them by direction keeps the // RIGHT path byte-identical while giving DOWN/UP correct screen dimensions. - var lastLayer = columnX.Length - 1; - var alongTotal = columnX[lastLayer] + maxColWidth[lastLayer] + Padding; + // + // ColumnX/MaxColWidth are only populated when ComponentPacker's single-component fast path ran + // the stages directly on this graph (the byte-identical case the equivalence tests cover); for a + // multi-component (packed) graph, ComponentPacker deliberately leaves them empty, since the + // shelf-packed components no longer share one aligned layer/column structure. In that case the + // along-extent is instead computed the same bounding-box way the cross-extent already is below. var transposed = direction is LayoutDirection.Down or LayoutDirection.Up; + double alongTotal; + if (columnX.Length > 0) + { + var lastLayer = columnX.Length - 1; + alongTotal = columnX[lastLayer] + maxColWidth[lastLayer] + Padding; + } + else + { + alongTotal = Padding; + for (var i = 0; i < n; i++) + { + var alongFar = transposed + ? augY[i] + nodes[i].Height + : augX[i] + nodes[i].Width; + alongTotal = Math.Max(alongTotal, alongFar + Padding); + } + } + var crossTotal = Padding; for (var i = 0; i < n; i++) { @@ -213,7 +271,7 @@ public static LayerResult Place( var totalWidth = transposed ? crossTotal : alongTotal; var totalHeight = transposed ? alongTotal : crossTotal; - return new LayerResult(rects, totalWidth, totalHeight, graph.NodeLayers, graph.Waypoints) + return new LayerResult(rects, totalWidth, totalHeight, graph.NodeLayers, waypoints) { AcyclicEdges = graph.Acyclic, AcyclicOriginalIndex = graph.AcyclicOriginalIndex, diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/AxisTransform.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/AxisTransform.cs index f10c5fe..458fda1 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/AxisTransform.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/AxisTransform.cs @@ -37,15 +37,27 @@ internal sealed class AxisTransform : ILayoutStage /// height are swapped so the along-axis (layer spacing) is driven by the node height. For /// / this is a no-op, which /// keeps those pipelines byte-identical. + /// + /// Idempotent: guarded by so a second call on the + /// same graph (for example when is composed as an inner stage of a + /// that already normalized it) does not swap the axes back. + /// /// public static void NormalizeInputAxes(LayeredGraph graph) { ArgumentNullException.ThrowIfNull(graph); + if (graph.InputAxesNormalized) + { + return; + } + if (graph.Direction is LayoutDirection.Down or LayoutDirection.Up) { graph.SwapNodeAxes(); } + + graph.InputAxesNormalized = true; } /// diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/ComponentPacker.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/ComponentPacker.cs index 19b0f01..b047728 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/ComponentPacker.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/ComponentPacker.cs @@ -29,6 +29,30 @@ namespace DemaConsulting.Rendering.Layout.Engine.Layered; /// and nodes within a component by ascending original index — so renders are reproducible. /// The stage is stateless and may be shared across pipelines. /// +/// +/// begins by calling on the supplied +/// graph, exactly as does before its own stage sequence. This +/// makes a fully self-contained stage regardless of call site: any caller +/// (direct or through a wrapping pipeline) gets correct Down/Up axis handling without also having to +/// remember to normalize first. is idempotent +/// ( guards the swap), so composing +/// as an inner stage of a that already +/// normalized the same graph is safe and does not double-swap the Down/Up axes. +/// +/// +/// Not wired into hierarchical's recursive (per-level) stage sequence. +/// (used by the hierarchical algorithm's +/// per-container flat runs) intentionally does not route through . Each +/// level graph built by MergeRegionGraphAssembler already carries hierarchy-crossing dummies with +/// cross-level pinning constraints (PinIncomingCrossings) that this type's independent +/// per-component sub-graph layout does not know how to preserve — splitting a level graph into +/// components would also require splitting and re-merging that crossing-pinning bookkeeping, which is a +/// materially larger, riskier change than this type's scope. A level graph is also typically already a +/// single connected component by construction in the common case, since a container's children are +/// grouped content. This is a deliberate, discoverable gap rather than a silent omission: a future +/// enhancement could teach (or a hierarchy-aware sibling) to split +/// individual recursive levels, but doing so is out of scope today. +/// /// internal sealed class ComponentPacker : ILayoutStage { @@ -85,6 +109,11 @@ public void Apply(LayeredGraph graph) { ArgumentNullException.ThrowIfNull(graph); + // Normalize the input node axes for the requested direction before any stage runs, exactly as + // LayeredLayoutPipeline.Run does before its own stage sequence, so this stage is self-contained + // regardless of call site (a no-op for RIGHT/LEFT). + AxisTransform.NormalizeInputAxes(graph); + // An empty graph has nothing to detect, lay out, or pack; the downstream stages would also // fail on an empty augmented graph, so short-circuit here. if (graph.N == 0) @@ -249,10 +278,12 @@ private void LayoutAndPackComponents(LayeredGraph graph, List> compone // de-duplicates identical directed pairs, and reverses back edges), so the merged graph exposes // the acyclic edge set with its aligned waypoints — the same contract the single-component fast // path produces via RunInner — rather than one entry per input edge. Consumers key a - // (source, target) lookup on Acyclic to recover each input edge's route. + // (source, target) lookup on Acyclic to recover each input edge's route, or use the parallel + // AcyclicOriginalIndex to recover the 0-based index into the parent graph's own Edges list. var mergedAcyclic = new List(); var mergedReversed = new List(); var mergedWaypoints = new List>(); + var mergedOriginalIndex = new List(); foreach (var layout in layouts) { @@ -280,6 +311,7 @@ private void LayoutAndPackComponents(LayeredGraph graph, List> compone mergedAcyclic.Add(layout.AcyclicEdges[k]); mergedReversed.Add(layout.AcyclicReversed[k]); mergedWaypoints.Add(translated); + mergedOriginalIndex.Add(layout.AcyclicOriginalIndex[k]); } } @@ -289,6 +321,7 @@ private void LayoutAndPackComponents(LayeredGraph graph, List> compone graph.Acyclic = mergedAcyclic; graph.AcyclicReversed = [.. mergedReversed]; graph.Waypoints = mergedWaypoints; + graph.AcyclicOriginalIndex = mergedOriginalIndex; } /// @@ -315,8 +348,11 @@ private ComponentLayout LayoutComponent(LayeredGraph graph, List members) } // Collect this component's edges (in original edge order for determinism), remapping endpoints - // to local indices. + // to local indices. localToParentEdge[i] records the parent graph's Edges index that + // localEdges[i] originated from, so the child's own AcyclicOriginalIndex (which indexes into + // localEdges) can be remapped back to the parent's Edges index once the child has run. var localEdges = new List(); + var localToParentEdge = new List(); for (var e = 0; e < graph.Edges.Count; e++) { var edge = graph.Edges[e]; @@ -324,6 +360,7 @@ private ComponentLayout LayoutComponent(LayeredGraph graph, List members) origToLocal.TryGetValue(edge.Target, out var localTarget)) { localEdges.Add(new LayerEdge(localSource, localTarget)); + localToParentEdge.Add(e); } } @@ -338,12 +375,18 @@ private ComponentLayout LayoutComponent(LayeredGraph graph, List members) // Map the child's acyclic edges (local indices) back to original node indices so the merged // parent graph exposes one (source, target) polyline per acyclic edge, aligned with - // child.Waypoints and child.AcyclicReversed. + // child.Waypoints and child.AcyclicReversed. Also remap the child's own AcyclicOriginalIndex + // (which indexes into localEdges) through localToParentEdge, so the merged parent graph's + // AcyclicOriginalIndex indexes into the parent's own Edges list, exactly like the + // single-component fast path (RunInner) already produces via CycleBreaker running directly on + // the parent graph. var acyclicEdges = new LayerEdge[child.Acyclic.Count]; + var acyclicOriginalIndex = new int[child.Acyclic.Count]; for (var k = 0; k < child.Acyclic.Count; k++) { var edge = child.Acyclic[k]; acyclicEdges[k] = new LayerEdge(members[edge.Source], members[edge.Target]); + acyclicOriginalIndex[k] = localToParentEdge[child.AcyclicOriginalIndex[k]]; } // Compute the content bounding box over the real nodes only (dummies are excluded). @@ -380,6 +423,7 @@ private ComponentLayout LayoutComponent(LayeredGraph graph, List members) AcyclicEdges = acyclicEdges, AcyclicReversed = child.AcyclicReversed, EdgeWaypoints = child.Waypoints, + AcyclicOriginalIndex = acyclicOriginalIndex, }; } @@ -461,6 +505,12 @@ private sealed class ComponentLayout /// Routed waypoints for each acyclic edge, in order. public required IReadOnlyList> EdgeWaypoints { get; init; } + /// + /// The parent graph's own Edges index that each entry in + /// originated from, remapped from the child sub-graph's local edge indices. + /// + public required IReadOnlyList AcyclicOriginalIndex { get; init; } + /// Packed shelf X offset assigned to this component. public double OffsetX { get; set; } diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs index 1faa3ec..b4a86e7 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs @@ -129,6 +129,21 @@ public LayeredGraph( /// Gets the requested layout flow direction. public LayoutDirection Direction { get; } + /// + /// Gets or sets whether has already swapped this + /// graph's node axes. + /// + /// + /// is called at every composable entry point + /// (, , and + /// ) so each is self-contained regardless of call site. When + /// is composed as an inner stage of a + /// that already normalized the same graph, a second unguarded swap would undo the first and hand the + /// downstream stages the wrong along/cross extents. This flag makes the normalization idempotent so + /// composing normalizing stages is always safe. + /// + public bool InputAxesNormalized { get; set; } + /// /// Gets or sets the minimum straight entry approach reserved for a reversed (back) edge's final /// sub-edge — the wrap-around corridor that ends at the true target where the consumer draws the diff --git a/src/DemaConsulting.Rendering.Layout/Engine/LayoutTreePacker.cs b/src/DemaConsulting.Rendering.Layout/Engine/LayoutTreePacker.cs new file mode 100644 index 0000000..35cb968 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/LayoutTreePacker.cs @@ -0,0 +1,198 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine; + +/// +/// Reusable shelf-packer that merges several independently-placed s (each +/// already a complete, self-contained canvas, typically produced by routing one connected component of +/// a graph through a different bundled leaf algorithm) into a single combined . +/// +/// +/// +/// This is the -level counterpart to +/// : both split a disconnected input into independently-laid-out +/// pieces and then pack the pieces into one non-overlapping arrangement using the same greedy shelf +/// heuristic (target row width = the wider of the widest piece and sqrt(totalArea) * aspect). +/// They are legitimately distinct, small implementations rather than true duplication, because they +/// operate on different types at different levels: works on the +/// layered engine's internal Rect/Point2D representation for components that all share +/// one algorithm (the layered pipeline), while this type works on already-rendered +/// s that may have come from different algorithms entirely (for +/// example one component routed through layered and another through hierarchical), +/// which is exactly the shape needs for its per-component routing. +/// +/// +/// Translation. Every node in a packed sub-tree is shifted by that sub-tree's +/// assigned shelf offset, recursively: a 's own X/Y and every +/// nested node, a 's every +/// point, and a 's CentreX/ +/// CentreY. coordinates are absolute (not parent-relative — see its +/// own remarks), so translating a placed sub-tree requires this recursive walk rather than a single +/// offset applied once at the root. +/// +/// +/// Unknown node types fail loudly. switches over the +/// closed set of subtypes the three bundled leaf algorithms +/// (layered, hierarchical, containment) are confirmed to emit — +/// , , and (verified by +/// grepping every new LayoutBox(/new LayoutLine(/new LayoutPort( construction +/// site across LayeredLayoutAlgorithm.cs, HierarchicalLayoutAlgorithm.cs, and +/// ContainmentLayoutAlgorithm.cs). An unrecognized node type throws +/// instead of being silently skipped. This is a deliberate +/// divergence from the renderer convention documented on ("unknown +/// subtypes should be skipped for forward compatibility"): a renderer skipping an unknown node still +/// draws every other node correctly, but a packer silently leaving an unknown node's coordinates +/// untranslated would place it at the wrong (pre-pack) position with no visible sign of the error — +/// a worse failure mode than a hard, immediate exception naming the offending type. +/// +/// +/// Out of scope: force-directed placement for sparse components. The shelf packer +/// always wraps a component onto the next row once the running row width would exceed the target, +/// which suits the compact rectangular components every bundled leaf algorithm produces. A future +/// enhancement could add a force-directed/spring-relaxation fallback that packs many small, sparse +/// components more organically (for example a large field of unrelated singleton nodes) instead of a +/// strict grid of shelves, but that is a materially different, self-contained algorithm and is +/// explicitly out of scope for this type today. +/// +/// +internal static class LayoutTreePacker +{ + /// + /// Packs the supplied sub-trees into one combined using a greedy shelf + /// packer, translating every placed node (recursively) by its assigned shelf offset. + /// + /// + /// The independently-placed sub-trees to merge, each a complete, self-contained canvas. Must not be + /// ; may be empty (yielding a degenerate empty tree) or contain a single tree + /// (its coordinates require no translation, but its node types are still validated against the + /// closed set recognizes). + /// + /// Gap, in logical pixels, kept between adjacent packed sub-trees. + /// + /// Target width-to-height multiplier controlling the packed row width (larger values produce wider, + /// shorter arrangements). + /// + /// The combined, packed . + /// Thrown when is . + /// + /// Thrown when a sub-tree contains a subtype outside the closed set this + /// packer knows how to translate (see the type's remarks). + /// + public static LayoutTree Pack(IReadOnlyList trees, double spacing, double aspect) + { + ArgumentNullException.ThrowIfNull(trees); + + if (trees.Count == 0) + { + return new LayoutTree(0.0, 0.0, []); + } + + if (trees.Count == 1) + { + // A single tree needs no coordinate translation (offset (0, 0)), but its node types must + // still be validated against the closed set TranslateNode recognizes — routing it through + // TranslateNode here (rather than returning the tree unchanged) closes a gap where an + // unrecognized LayoutNode subtype in singleton input would silently bypass the same + // NotSupportedException every multi-tree pack enforces. + var singleton = trees[0]; + return singleton with + { + Nodes = [.. singleton.Nodes.Select(node => TranslateNode(node, 0.0, 0.0))], + }; + } + + var totalArea = 0.0; + var widest = 0.0; + foreach (var tree in trees) + { + totalArea += tree.Width * tree.Height; + widest = Math.Max(widest, tree.Width); + } + + var targetRowWidth = Math.Max(widest, Math.Sqrt(totalArea) * aspect); + + var offsetX = new double[trees.Count]; + var offsetY = new double[trees.Count]; + var cursorX = 0.0; + var shelfTop = 0.0; + var shelfHeight = 0.0; + var totalWidth = 0.0; + for (var i = 0; i < trees.Count; i++) + { + var tree = trees[i]; + + // Wrap to a new shelf when the running row width would exceed the target (but never leave + // a shelf empty — an over-wide tree sits alone on its own shelf). + if (cursorX > 0.0 && cursorX + tree.Width > targetRowWidth) + { + shelfTop += shelfHeight + spacing; + cursorX = 0.0; + shelfHeight = 0.0; + } + + offsetX[i] = cursorX; + offsetY[i] = shelfTop; + cursorX += tree.Width + spacing; + shelfHeight = Math.Max(shelfHeight, tree.Height); + totalWidth = Math.Max(totalWidth, offsetX[i] + tree.Width); + } + + var totalHeight = shelfTop + shelfHeight; + + var mergedNodes = new List(); + var mergedWarnings = new List(); + for (var i = 0; i < trees.Count; i++) + { + var tree = trees[i]; + var dx = offsetX[i]; + var dy = offsetY[i]; + + foreach (var node in tree.Nodes) + { + mergedNodes.Add(TranslateNode(node, dx, dy)); + } + + mergedWarnings.AddRange(tree.Warnings); + } + + return new LayoutTree(totalWidth, totalHeight, mergedNodes) { Warnings = mergedWarnings }; + } + + /// + /// Recursively translates a single placed (and any nested nodes it + /// carries) by /. + /// + /// The node to translate. + /// Horizontal offset, in logical pixels. + /// Vertical offset, in logical pixels. + /// A new node of the same kind, shifted by the given offset. + /// + /// Thrown when is a subtype outside this packer's + /// closed, verified set (see the type's remarks). + /// + private static LayoutNode TranslateNode(LayoutNode node, double dx, double dy) => node switch + { + LayoutBox box => box with + { + X = box.X + dx, + Y = box.Y + dy, + Children = [.. box.Children.Select(child => TranslateNode(child, dx, dy))], + }, + LayoutLine line => line with + { + Waypoints = [.. line.Waypoints.Select(point => new Point2D(point.X + dx, point.Y + dy))], + }, + LayoutPort port => port with + { + CentreX = port.CentreX + dx, + CentreY = port.CentreY + dy, + }, + _ => throw new NotSupportedException( + $"LayoutTreePacker cannot translate unrecognized LayoutNode subtype '{node.GetType().Name}'. " + + "The closed set of node types the bundled leaf algorithms emit is LayoutBox, LayoutLine, and " + + "LayoutPort; if a new leaf algorithm or node type is introduced, TranslateNode must be " + + "extended to cover it."), + }; +} diff --git a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs index e69ebe5..8c1f280 100644 --- a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs @@ -3,6 +3,7 @@ // using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout.Engine; using DemaConsulting.Rendering.Layout.Engine.Layered; namespace DemaConsulting.Rendering.Layout; @@ -308,16 +309,31 @@ private LayoutTree LayoutScope(LayoutGraph graph, LayoutOptions effective) if (boundaryPorts.Count == 0) { // Hard invariant: a hierarchical scope with containers but zero boundary ports keeps its - // existing leaf-pass + cross-container-router output, byte-for-byte unchanged. - var leafCrossLines = RouteCrossContainerEdges(routedEdges, composed, indexOf, descendantToDirect, effective); - var leafNodes = new List(composed.Length + placedLines.Count + placedPorts.Count + leafCrossLines.Count); - leafNodes.AddRange(composed); - leafNodes.AddRange(placedLines); - leafNodes.AddRange(placedPorts); + // existing leaf-pass + cross-container-router output, byte-for-byte unchanged — except that + // a pair of horizontally-adjacent sibling containers joined by a fan of parallel + // cross-container connectors first has the gap between them widened so those connectors get + // distinct routing lanes. A pair joined by zero or one cross-container edge is never widened, + // so every existing single-edge/no-edge scope stays byte-identical. + var (widenedBoxes, widenedLines, widenedPorts, widenedWidth) = WidenSiblingContainerGaps( + composed, indexOf, routedEdges, descendantToDirect, placedLines, placedPorts, placed.Width); + + var leafCrossLines = RouteCrossContainerEdges(routedEdges, widenedBoxes, indexOf, descendantToDirect, effective); + var leafNodes = new List( + widenedBoxes.Length + widenedLines.Count + widenedPorts.Count + leafCrossLines.Count); + leafNodes.AddRange(widenedBoxes); + leafNodes.AddRange(widenedLines); + leafNodes.AddRange(widenedPorts); leafNodes.AddRange(leafCrossLines); - return new LayoutTree(placed.Width, placed.Height, leafNodes); + return new LayoutTree(widenedWidth, placed.Height, leafNodes); } + // Deferred (see ROADMAP.md): extend the edge-count-aware sibling-gap widening above to the + // boundary-port combined-pass path below. That path composes geometry through the recursive + // merge-region pipeline (MergeRegionGraphAssembler / RunRecursiveWithCascadingSizing / + // MergeRegionDecomposer), not the ComposeBoxes path the widening pass operates on, so it needs + // its own separately-designed widening inside the corridor router rather than a post-placement + // half-plane shift. This is an explicit, disclosed scope reduction, not an oversight. + // Single combined pass (ELK-style recursive hierarchy handling): assemble the hierarchy-aware // graph spanning every nesting level, lay it all out in one coordinated placement (growing each // container to fit its placed interior and cascading that growth outward), then project the @@ -687,6 +703,189 @@ private static List RouteCrossContainerEdges( return new List(ConnectorRouter.Route(boxesForRouting, connections, routeOptions)); } + /// + /// Widens the horizontal gap between horizontally-adjacent sibling container boxes that a fan of + /// parallel cross-container connectors must route through, so those connectors get distinct + /// orthogonal lanes instead of being crushed into one narrow channel. + /// + /// + /// + /// The leaf algorithm sizes the gap between two placed sibling boxes from its own internal + /// spacing, which is unaware of the cross-container edges this scope's router + /// () is about to fan through that gap — those edges live + /// only in , never in the sized view the leaf pass laid out. This + /// pass closes that gap: it counts the cross-container edges between each pair of direct-member + /// containers, and for any pair placed side by side (their vertical extents overlap and no third + /// box lies between them) widens their separating gap to the same corridor width the layered + /// pipeline reserves for that many parallel edges, via the shared + /// . A pair joined by zero or one cross-container edge is never + /// widened, so a scope without a genuine fan is returned byte-for-byte unchanged. + /// + /// + /// Widening is applied as a rigid half-plane shift: inserting extra space at a vertical cut line + /// translates every box, line, and port at or right of that cut by the same amount, which keeps + /// all already-placed geometry (container interiors, internal leaf connectors, ports) internally + /// consistent and non-overlapping. This is sound for the flat side-by-side sibling arrangement + /// this no-boundary-port path produces; a box straddling a cut line is not expected there and is + /// the reason the combined-pass boundary-port path is handled separately (see the caller's + /// deferral note referencing ROADMAP.md). + /// + /// + /// The composed top-level boxes of this scope, aligned with its nodes by index. + /// Map from each direct-member node to its positional index in . + /// The cross-container edges this scope routes, per . + /// Map from every descendant node to the direct member that owns it. + /// The leaf pass's placed connector lines for this scope. + /// The leaf pass's placed ports for this scope. + /// The scope's current total width, in logical pixels, before any widening. + /// + /// The (possibly) widened boxes, lines, and ports, plus the scope's total width grown by the sum of + /// every inserted gap. When no pair qualifies for widening, the inputs are returned unchanged. + /// + private static (LayoutBox[] Composed, List Lines, List Ports, double Width) WidenSiblingContainerGaps( + LayoutBox[] composed, + Dictionary indexOf, + List routedEdges, + Dictionary descendantToDirect, + List placedLines, + List placedPorts, + double width) + { + // Count the cross-container edges between each unordered pair of direct-member containers, + // resolving each endpoint to the direct member that owns it exactly as the router does. + var pairCounts = new Dictionary<(int First, int Second), int>(); + foreach (var edge in routedEdges) + { + var sourceDirect = descendantToDirect[(LayoutGraphNode)edge.Source]; + var targetDirect = descendantToDirect[(LayoutGraphNode)edge.Target]; + var s = indexOf[sourceDirect]; + var t = indexOf[targetDirect]; + if (s == t) + { + continue; + } + + var key = s < t ? (s, t) : (t, s); + pairCounts.TryGetValue(key, out var existing); + pairCounts[key] = existing + 1; + } + + // Collect a horizontal cut for every side-by-side pair whose connector fan needs more room than + // the leaf pass already left. Each cut records the original left edge of the right box and the + // extra width to insert there. + var cuts = new List<(double CutX, double Extra)>(); + foreach (var ((first, second), count) in pairCounts) + { + if (count <= 1) + { + continue; + } + + var boxA = composed[first]; + var boxB = composed[second]; + + // Require a genuine side-by-side placement: the two boxes' vertical extents overlap and one + // sits entirely to the left of the other with a non-negative gap between them. + var verticalOverlap = boxA.Y < boxB.Y + boxB.Height && boxB.Y < boxA.Y + boxA.Height; + if (!verticalOverlap) + { + continue; + } + + var left = boxA.X <= boxB.X ? boxA : boxB; + var right = boxA.X <= boxB.X ? boxB : boxA; + var gap = right.X - (left.X + left.Width); + if (gap < 0.0) + { + continue; + } + + // Skip the pair when a third box lies horizontally between them in the same vertical band: + // the two are not immediate neighbors, so inserting space at the right box would not widen + // the true channel the fan routes through. + var hasIntervening = false; + for (var k = 0; k < composed.Length; k++) + { + if (k == first || k == second) + { + continue; + } + + var other = composed[k]; + var otherVerticalOverlap = other.Y < right.Y + right.Height && right.Y < other.Y + other.Height; + if (otherVerticalOverlap && + other.X >= left.X + left.Width && + other.X + other.Width <= right.X) + { + hasIntervening = true; + break; + } + } + + if (hasIntervening) + { + continue; + } + + var extra = EdgeCountGapWidener.Widen(gap, count) - gap; + if (extra > 0.0) + { + cuts.Add((right.X, extra)); + } + } + + // No qualifying pair: return the inputs unchanged so the scope stays byte-identical. + if (cuts.Count == 0) + { + return (composed, placedLines, placedPorts, width); + } + + // Apply every cut as a rigid half-plane shift: an element is pushed right by the summed extra of + // all cuts at or left of its own left edge. Using each element's leftmost coordinate against the + // original (pre-shift) cut positions makes overlapping cuts compose additively and deterministic. + double ShiftFor(double leftX) => cuts.Where(cut => cut.CutX <= leftX).Sum(cut => cut.Extra); + + var shiftedBoxes = new LayoutBox[composed.Length]; + for (var i = 0; i < composed.Length; i++) + { + var shift = ShiftFor(composed[i].X); + shiftedBoxes[i] = shift > 0.0 ? (LayoutBox)Translate(composed[i], shift, 0.0) : composed[i]; + } + + var shiftedLines = new List(placedLines.Count); + foreach (var line in placedLines) + { + // Shift each waypoint by its own individual position against the cuts, not the line's + // leftmost point against the cuts: an unrelated line between two other top-level siblings + // can geometrically thread through the x-region of an inserted cut (a common outcome for + // Sugiyama-style routing), and shifting such a straddling line as one rigid unit would move + // its far end away from a box that never moved, detaching the connector from its endpoint. + var changed = false; + var waypoints = new List(line.Waypoints.Count); + foreach (var point in line.Waypoints) + { + var pointShift = ShiftFor(point.X); + if (pointShift > 0.0) + { + changed = true; + } + + waypoints.Add(pointShift > 0.0 ? new Point2D(point.X + pointShift, point.Y) : point); + } + + shiftedLines.Add(changed ? line with { Waypoints = waypoints } : line); + } + + var shiftedPorts = new List(placedPorts.Count); + foreach (var port in placedPorts) + { + var shift = ShiftFor(port.CentreX); + shiftedPorts.Add(shift > 0.0 ? (LayoutPort)Translate(port, shift, 0.0) : port); + } + + return (shiftedBoxes, shiftedLines, shiftedPorts, width + cuts.Sum(cut => cut.Extra)); + } + /// /// Classifies every edge of , purely from graph structure, into the edges /// this scope's leaf algorithm should route locally and the edges this scope's router must handle @@ -713,12 +912,15 @@ private static List RouteCrossContainerEdges( /// leaf algorithm exactly like a flat, container-free graph would route it. This scope's own /// router (, built on the box-only /// /) has no port concept, so a port edge - /// that would otherwise be classified as a genuine cross-container edge — or promoted into the - /// router's batch because it shares a box with one — instead throws + /// that would otherwise be classified as a genuine cross-container edge instead throws /// : full boundary-crossing port support (anchoring, routing) /// is a separate, not-yet-designed Phase 2 effort (see ROADMAP.md), so this deliberately fails /// loudly rather than silently dropping the edge or routing it incorrectly as a plain box - /// connector. + /// connector. A same-scope port edge is never promoted into the router's batch even when one of + /// its direct members is also touched by an unrelated genuine cross-container edge: since the + /// edge itself never crosses a container boundary, the leaf algorithm can always route it + /// correctly, so promoting it would trade a working local route for an unsupported one the + /// box-only router cannot execute. /// /// /// The scope whose edges are classified. @@ -726,9 +928,11 @@ private static List RouteCrossContainerEdges( /// Map from every descendant's named port to the node that owns it. /// The edges to route locally, and the edges this scope's router must handle. /// - /// Thrown when a named-port edge would cross a container boundary (a genuine cross-container edge, - /// or a same-scope edge promoted into the router's batch because it shares a box with one) — this - /// hierarchical engine does not yet support routing or anchoring a port across a container boundary. + /// Thrown when a named-port edge is a genuine cross-container edge (at least one endpoint nested + /// relative to this scope) — this hierarchical engine does not yet support routing or anchoring a + /// port across a container boundary. A same-scope port edge is never promoted into the router's + /// batch, so it never triggers this exception even when it shares a box with a genuine + /// cross-container edge. /// private static (HashSet LeafEdges, List RoutedEdges) ClassifyEdges( LayoutGraph graph, @@ -787,15 +991,15 @@ private static (HashSet LeafEdges, List Routed var leafEdges = new HashSet(); foreach (var (edge, sourceDirect, targetDirect) in directDirect) { - if (conflicted.Contains(sourceDirect) || conflicted.Contains(targetDirect)) + // A port edge never crosses a container boundary in this branch (both direct members are + // literally direct), so it can always be routed locally by the leaf algorithm — it is never + // promoted into the box-only router's batch, even when a sibling box it shares is also + // touched by an unrelated genuine cross-container edge. + var isPortEdge = edge.Source is LayoutGraphPort || edge.Target is LayoutGraphPort; + if (!isPortEdge && (conflicted.Contains(sourceDirect) || conflicted.Contains(targetDirect))) { // Promoted into the router's batch because it shares a box with a genuine - // cross-container edge; the same box-only router limitation applies here too. - if (edge.Source is LayoutGraphPort || edge.Target is LayoutGraphPort) - { - throw new NotSupportedException(boundaryPortMessage); - } - + // cross-container edge, so one coordinated pass anchors every connector on that face. routedEdges.Add(edge); } else diff --git a/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs b/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs index 6d66554..26750f5 100644 --- a/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs +++ b/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs @@ -21,11 +21,12 @@ namespace DemaConsulting.Rendering.Layout; /// the dependency direction intact (model <- Abstractions <- Layout). /// /// -/// The returned registry contains the three bundled algorithms: +/// The returned registry contains the four bundled algorithms: /// (layered), -/// (containment), and (hierarchical). Each -/// call returns a fresh, independently mutable registry, so a caller may register additional -/// algorithms or replace a bundled one without affecting any other caller. +/// (containment), (hierarchical), and +/// (auto). Each call returns a fresh, independently +/// mutable registry, so a caller may register additional algorithms or replace a bundled one +/// without affecting any other caller. /// /// /// @@ -42,10 +43,11 @@ namespace DemaConsulting.Rendering.Layout; public static class LayoutAlgorithms { /// - /// Creates a populated with the three bundled layout + /// Creates a populated with the four bundled layout /// algorithms — (layered), - /// (containment), and - /// (hierarchical). + /// (containment), + /// (hierarchical), and + /// (auto). /// /// /// A new registry from which each bundled algorithm resolves by its . @@ -55,5 +57,6 @@ public static LayoutAlgorithmRegistry CreateDefaultRegistry() => new LayoutAlgorithmRegistry() .Register(new LayeredLayoutAlgorithm()) .Register(new ContainmentLayoutAlgorithm()) - .Register(new HierarchicalLayoutAlgorithm()); + .Register(new HierarchicalLayoutAlgorithm()) + .Register(new AutoLayoutAlgorithm()); } diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index 1a4ee5e..1454e2d 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -5,23 +5,52 @@ namespace DemaConsulting.Rendering.Gallery; /// -/// One generated showcase image: its stable output filename plus the alt text and caption used in -/// the browsable README.md index. +/// One generated showcase image: its stable output filename (folder-prefixed with its owning +/// group's folder) plus the alt text and caption used in the browsable README.md pages. /// -/// Stable output filename (for example layered-pipeline.svg). +/// Stable output filename (for example flow-pipeline/layered-pipeline.svg). /// Alt text for the Markdown image link. /// One-line caption shown beneath the image link in the index. -internal sealed record GalleryImage(string FileName, string Alt, string Caption); +/// +/// Whether this image is one of a small, hand-picked set shown on the top-level index's "taste of +/// what's inside" section. Opt-in by design: an image only ever appears on the top-level index if a +/// person deliberately chose it (with this same, real caption, not a generic placeholder), so a +/// deliberate contrast/baseline image can never surface there bare and out of context by accident. +/// +internal sealed record GalleryImage( + string FileName, + string Alt, + string Caption, + bool IsTopIndexHighlight = false); /// -/// A titled group of related showcase images with a short introduction, rendered as one section of -/// the README.md index. +/// A titled section of related showcase images with a short introduction, rendered as one section +/// of a group's README.md page. /// /// Section heading. /// One-paragraph description of what the section demonstrates. /// The images belonging to the section, in display order. internal sealed record GallerySection(string Title, string Intro, IReadOnlyList Images); +/// +/// A topology-based group of related sections, rendered as its own browsable +/// docs/gallery/<Folder>/README.md page and summarized on the top-level index. +/// +/// The group's output subfolder under docs/gallery (for example flow-pipeline). +/// Group heading, shown on both the top-level index and the group's own page. +/// One-paragraph description of what the group demonstrates, shown on its own page. +/// +/// A single short phrase describing the group, shown in the top-level index's group table (keep this +/// brief — it is a table cell, not a paragraph). +/// +/// The group's sections, in display order. +internal sealed record GalleryGroup( + string Folder, + string Title, + string Intro, + string ShortSummary, + IReadOnlyList Sections); + /// /// The single source of truth for the gallery showcase: the stable output filenames and the /// structure of the browsable index. The showcase facts render to these filenames and the index @@ -30,286 +59,597 @@ internal sealed record GallerySection(string Title, string Intro, IReadOnlyList< internal static class GalleryCatalog { // Stable output filenames. Facts and the index both reference these constants so a rename is a - // 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"; - public const string DirectionRightSvg = "direction-right.svg"; - public const string DirectionDownSvg = "direction-down.svg"; - public const string MixedDirectionNestedSvg = "mixed-direction-nested.svg"; - public const string ThemeLightPng = "theme-light.png"; - public const string ThemeDarkPng = "theme-dark.png"; - public const string ThemePrintPng = "theme-print.png"; - public const string LayeredPipelinePng = "layered-pipeline.png"; - public const string HierarchicalNestedPng = "hierarchical-nested.png"; - public const string BoxAppearanceSvg = "box-appearance.svg"; - public const string FolderTopFaceAnchorSvg = "folder-top-face-anchor.svg"; - public const string ShapeGallerySvg = "shape-gallery.svg"; - public const string ParallelEdgesMergedSvg = "parallel-edges-merged.svg"; - public const string ParallelEdgesPreservedSvg = "parallel-edges-preserved.svg"; - public const string ParallelEdgesPreservedVerticalSvg = "parallel-edges-preserved-vertical.svg"; - public const string PortsShowcaseHorizontalSvg = "ports-showcase-horizontal.svg"; - public const string PortsShowcaseVerticalSvg = "ports-showcase-vertical.svg"; - public const string PortsShowcaseMultiConnectorHorizontalSvg = "ports-showcase-multi-connector-horizontal.svg"; - public const string PortsShowcaseMultiConnectorVerticalSvg = "ports-showcase-multi-connector-vertical.svg"; - public const string PortsShowcaseUnlabeledFanOutSvg = "ports-showcase-unlabeled-fan-out.svg"; - public const string BoundaryPortsShowcaseHorizontalSvg = "boundary-ports-showcase-horizontal.svg"; - public const string BoundaryPortsShowcaseVerticalSvg = "boundary-ports-showcase-vertical.svg"; - public const string BoundaryPortsShowcaseDeepChainSvg = "boundary-ports-showcase-deep-chain.svg"; - public const string PortsShowcaseHorizontalPng = "ports-showcase-horizontal.png"; - public const string PortsShowcaseVerticalPng = "ports-showcase-vertical.png"; - public const string PortsShowcaseMultiConnectorHorizontalPng = "ports-showcase-multi-connector-horizontal.png"; - public const string PortsShowcaseMultiConnectorVerticalPng = "ports-showcase-multi-connector-vertical.png"; - public const string PortsShowcaseUnlabeledFanOutPng = "ports-showcase-unlabeled-fan-out.png"; - public const string ParallelEdgesMergedPng = "parallel-edges-merged.png"; - public const string ParallelEdgesPreservedPng = "parallel-edges-preserved.png"; - public const string ParallelEdgesPreservedVerticalPng = "parallel-edges-preserved-vertical.png"; - public const string BoundaryPortsShowcaseHorizontalPng = "boundary-ports-showcase-horizontal.png"; - public const string BoundaryPortsShowcaseVerticalPng = "boundary-ports-showcase-vertical.png"; - public const string BoundaryPortsShowcaseDeepChainPng = "boundary-ports-showcase-deep-chain.png"; + // single edit that keeps the committed artifacts and the index in lockstep. Each is prefixed with + // its owning group's folder so the generated file lands directly in the group's own subfolder. + public const string LayeredPipelineSvg = "flow-pipeline/layered-pipeline.svg"; + public const string DirectionRightSvg = "flow-pipeline/direction-right.svg"; + public const string DirectionDownSvg = "flow-pipeline/direction-down.svg"; + public const string MixedDirectionNestedSvg = "flow-pipeline/mixed-direction-nested.svg"; + public const string OrthogonalObstacleSvg = "flow-pipeline/orthogonal-obstacle.svg"; + public const string LayeredPipelinePng = "flow-pipeline/layered-pipeline.png"; + + public const string ContainmentPackedSvg = "connectivity-and-clusters/containment-packed.svg"; + public const string LayeredRegressionBaselineSvg = + "connectivity-and-clusters/layered-regression-baseline.svg"; + public const string AutoClusterPlusIsolatedSvg = + "connectivity-and-clusters/auto-cluster-plus-isolated.svg"; + public const string AutoMultipleDisconnectedClustersSvg = + "connectivity-and-clusters/auto-multiple-disconnected-clusters.svg"; + public const string LayeredMultipleDisconnectedClustersSvg = + "connectivity-and-clusters/layered-multiple-disconnected-clusters.svg"; + public const string AutoAllIsolatedSvg = "connectivity-and-clusters/auto-all-isolated.svg"; + public const string ContainmentManySmallWideBoxesSvg = + "connectivity-and-clusters/containment-many-small-wide-boxes.svg"; + + public const string HierarchicalNestedSvg = "nested-hierarchy/hierarchical-nested.svg"; + public const string HierarchicalNestedPng = "nested-hierarchy/hierarchical-nested.png"; + public const string AutoNestedRoutesHierarchicalSvg = + "nested-hierarchy/auto-nested-routes-hierarchical.svg"; + public const string BoundaryPortsShowcaseHorizontalSvg = + "nested-hierarchy/boundary-ports-showcase-horizontal.svg"; + public const string BoundaryPortsShowcaseHorizontalPng = + "nested-hierarchy/boundary-ports-showcase-horizontal.png"; + public const string BoundaryPortsShowcaseVerticalSvg = + "nested-hierarchy/boundary-ports-showcase-vertical.svg"; + public const string BoundaryPortsShowcaseVerticalPng = + "nested-hierarchy/boundary-ports-showcase-vertical.png"; + public const string BoundaryPortsShowcaseDeepChainSvg = + "nested-hierarchy/boundary-ports-showcase-deep-chain.svg"; + public const string BoundaryPortsShowcaseDeepChainPng = + "nested-hierarchy/boundary-ports-showcase-deep-chain.png"; + + public const string ParallelEdgesIntoCompartmentBoxSvg = + "parallel-edges-and-ports/parallel-edges-into-compartment-box.svg"; + public const string ParallelEdgesIntoCompartmentBoxSideBySideSvg = + "custom-rendering/parallel-edges-into-compartment-box-side-by-side.svg"; + public const string ParallelEdgesPreservedSvg = + "parallel-edges-and-ports/parallel-edges-preserved.svg"; + public const string ParallelEdgesPreservedPng = + "parallel-edges-and-ports/parallel-edges-preserved.png"; + public const string ParallelEdgesPreservedVerticalSvg = + "parallel-edges-and-ports/parallel-edges-preserved-vertical.svg"; + public const string ParallelEdgesPreservedVerticalPng = + "parallel-edges-and-ports/parallel-edges-preserved-vertical.png"; + public const string ParallelEdgesMergedSvg = "parallel-edges-and-ports/parallel-edges-merged.svg"; + public const string ParallelEdgesMergedPng = "parallel-edges-and-ports/parallel-edges-merged.png"; + public const string PortsShowcaseHorizontalSvg = + "parallel-edges-and-ports/ports-showcase-horizontal.svg"; + public const string PortsShowcaseHorizontalPng = + "parallel-edges-and-ports/ports-showcase-horizontal.png"; + public const string PortsShowcaseVerticalSvg = + "parallel-edges-and-ports/ports-showcase-vertical.svg"; + public const string PortsShowcaseVerticalPng = + "parallel-edges-and-ports/ports-showcase-vertical.png"; + public const string PortsShowcaseMultiConnectorHorizontalSvg = + "parallel-edges-and-ports/ports-showcase-multi-connector-horizontal.svg"; + public const string PortsShowcaseMultiConnectorHorizontalPng = + "parallel-edges-and-ports/ports-showcase-multi-connector-horizontal.png"; + public const string PortsShowcaseMultiConnectorVerticalSvg = + "parallel-edges-and-ports/ports-showcase-multi-connector-vertical.svg"; + public const string PortsShowcaseMultiConnectorVerticalPng = + "parallel-edges-and-ports/ports-showcase-multi-connector-vertical.png"; + public const string PortsShowcaseUnlabeledFanOutSvg = + "parallel-edges-and-ports/ports-showcase-unlabeled-fan-out.svg"; + public const string PortsShowcaseUnlabeledFanOutPng = + "parallel-edges-and-ports/ports-showcase-unlabeled-fan-out.png"; + public const string ContainmentParallelEdgesSideBySideSvg = + "parallel-edges-and-ports/containment-parallel-edges-side-by-side.svg"; + public const string HierarchicalParallelEdgesSideBySideSvg = + "parallel-edges-and-ports/hierarchical-parallel-edges-side-by-side.svg"; + + public const string BoxAppearanceSvg = "appearance-and-themes/box-appearance.svg"; + public const string FolderTopFaceAnchorSvg = "appearance-and-themes/folder-top-face-anchor.svg"; + public const string ShapeGallerySvg = "appearance-and-themes/shape-gallery.svg"; + public const string ThemeLightPng = "appearance-and-themes/theme-light.png"; + public const string ThemeDarkPng = "appearance-and-themes/theme-dark.png"; + public const string ThemePrintPng = "appearance-and-themes/theme-print.png"; + + public const string IsolatedNodeLayerGapSvg = "layout-regressions/isolated-node-layer-gap.svg"; + + /// Shared "Layout algorithms" section intro, reused by every topology-based group. + private const string LayoutAlgorithmsIntro = + "The bundled algorithms, each laying out the same kind of graph in its own style. Select one " + + "with the algorithm option and let the engine place the boxes and route any edges."; + + /// Shared "Raster output" section intro, reused by every group with a PNG showcase. + private const string RasterOutputIntro = + "One of the layout-algorithm diagrams above is rendered again here through the SkiaSharp " + + "raster path to PNG with the same dark theme, proving multi-format output."; - /// Gets the browsable sections of the gallery, in display order. - public static IReadOnlyList Sections { get; } = + /// Shared "The auto meta-algorithm" section intro, reused by every group showcasing it. + private const string AutoMetaAlgorithmIntro = + "The bundled \"auto\" algorithm splits the input graph into its connected top-level " + + "components, routes each component to whichever bundled leaf algorithm best suits its shape " + + "— layered for a connected cluster, hierarchical for any component holding a container node, " + + "containment for the shared bucket of childless, edgeless singletons — lays each piece out " + + "independently, and packs the results into one combined canvas."; + + /// Shared "Layout regressions" intro, reused by both its section and group forms. + private const string LayoutRegressionsIntro = + "Small graphs that reproduce and pin down a specific layout bug once fixed, kept as permanent " + + "visual and numeric evidence that it stays fixed."; + + /// Gets the browsable topology-based groups of the gallery, in display order. + public static IReadOnlyList Groups { get; } = [ - new GallerySection( - "Layout algorithms", - "The bundled algorithms, each laying out the same kind of node-and-edge graph in its own " - + "style. Select one with the algorithm option and let the engine place the boxes and route " - + "the edges.", - [ - new GalleryImage( - LayeredPipelineSvg, - "Layered pipeline diagram", - "A directed pipeline laid out left to right by the layered algorithm."), - new GalleryImage( - ContainmentPackedSvg, - "Containment packed diagram", - "Sibling boxes packed compactly by the containment algorithm."), - new GalleryImage( - HierarchicalNestedSvg, - "Hierarchical nested diagram", - "A container node holding a nested child graph, with a cross-container edge."), - ]), - new GallerySection( - "Layout regressions", - "Small graphs that reproduce and pin down a specific layout bug once fixed, kept as " - + "permanent visual and numeric evidence that it stays fixed.", + new GalleryGroup( + "flow-pipeline", + "Flow pipeline", + "A single connected component with a directed left-to-right or top-to-bottom flow — what " + + "a pipeline or flow diagram looks like once laid out and routed.", + "A single connected, directed flow — pipelines and direction changes", [ - new GalleryImage( - IsolatedNodeLayerGapSvg, - "A hub column with an isolated node beside a routing-corridor dummy", - "Regression coverage for the isolated-node layer-gap fix: a genuinely isolated " - + "node (Isolated, zero edges) shares its layer with a dummy bend point created by " - + "an unrelated long edge (LongSource to LongTarget). The crossing minimizer now " - + "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( + "Layout algorithms", + LayoutAlgorithmsIntro, + [ + new GalleryImage( + LayeredPipelineSvg, + "Layered pipeline diagram", + "A directed pipeline laid out left to right by the layered algorithm.", + IsTopIndexHighlight: true), + ]), + new GallerySection( + "Flow direction", + "The same directed graph laid out in two flow directions, selected with the " + + "direction option, plus a nested container overriding its own direction " + + "independently of its parent. A rightward flow arranges the layers left-to-right " + + "for block and pipeline diagrams; a downward flow arranges them top-to-bottom for " + + "action flows and state machines, swapping each node's width and height so layer " + + "spacing follows node height.", + [ + new GalleryImage( + DirectionRightSvg, + "Directed flow laid out left to right", + "The default rightward direction: layers progress left-to-right."), + new GalleryImage( + DirectionDownSvg, + "The same directed flow laid out top to bottom", + "The downward direction: the same graph's layers progress top-to-bottom."), + new GalleryImage( + MixedDirectionNestedSvg, + "A nested container flowing downward inside an outer rightward flow", + "A container's own direction override is honored independently of its " + + "parent: the outer flow runs left-to-right while the nested container " + + "runs top-to-bottom.", + IsTopIndexHighlight: true), + ]), + new GallerySection( + "Edge routing", + "Orthogonal connectors step around the boxes between their endpoints instead of " + + "cutting through them.", + [ + new GalleryImage( + OrthogonalObstacleSvg, + "Orthogonal edge routed around an obstacle", + "A connector routed orthogonally around an intervening container box."), + ]), + new GallerySection( + "Raster output", + RasterOutputIntro, + [ + new GalleryImage( + LayeredPipelinePng, + "Layered pipeline diagram as PNG", + "The layered pipeline rendered to a raster PNG image."), + ]), ]), - new GallerySection( - "Flow direction", - "The same directed graph laid out in two flow directions, selected with the direction " - + "option, plus a nested container overriding its own direction independently of its parent. " - + "A rightward flow arranges the layers left-to-right for block and pipeline diagrams; a " - + "downward flow arranges them top-to-bottom for action flows and state machines, swapping " - + "each node's width and height so layer spacing follows node height.", + new GalleryGroup( + "connectivity-and-clusters", + "Connectivity and clusters", + "Compares a normal, fully-connected graph against graphs with little or no connectivity " + + "between nodes — isolated singletons and/or multiple unrelated components — across the " + + "containment, auto, and layered algorithms.", + "Connected vs. disconnected input and clusters", [ - new GalleryImage( - DirectionRightSvg, - "Directed flow laid out left to right", - "The default rightward direction: layers progress left-to-right."), - new GalleryImage( - DirectionDownSvg, - "The same directed flow laid out top to bottom", - "The downward direction: the same graph's layers progress top-to-bottom."), - new GalleryImage( - MixedDirectionNestedSvg, - "A nested container flowing downward inside an outer rightward flow", - "A container's own direction override is honored independently of its parent: the " - + "outer flow runs left-to-right while the nested container runs top-to-bottom."), + new GallerySection( + "Layout algorithms", + LayoutAlgorithmsIntro, + [ + new GalleryImage( + ContainmentPackedSvg, + "Containment packed diagram", + "Sibling boxes packed compactly by the containment algorithm."), + ]), + new GallerySection( + "Baseline: a fully-connected graph, for contrast", + "Before the disconnected-graph comparisons in the next section, this baseline shows " + + "the same kind of small pipeline fully connected, laid out directly by the " + + "layered algorithm — the normal, connected case the \"auto\" meta-algorithm " + + "examples below are deliberately contrasted against.", + [ + new GalleryImage( + LayeredRegressionBaselineSvg, + "A small, fully-connected pipeline laid out by the layered algorithm", + "A baseline, fully-connected graph laid out directly by the layered " + + "algorithm, for visual contrast with the disconnected-graph diagrams in " + + "the next section."), + ]), + new GallerySection( + "The auto meta-algorithm", + AutoMetaAlgorithmIntro, + [ + new GalleryImage( + AutoClusterPlusIsolatedSvg, + "One connected cluster packed alongside three isolated singleton boxes", + "The \"auto\" algorithm routes the two-node cluster through the layered " + + "algorithm and gathers the three unrelated singletons into one shared " + + "bucket routed through the containment algorithm, then packs both pieces " + + "into one canvas."), + new GalleryImage( + AutoMultipleDisconnectedClustersSvg, + "Three disconnected two-node clusters, each routed and packed independently", + "The same three-cluster graph routed through \"auto\": each cluster is its " + + "own connected component, so each is laid out by the layered algorithm " + + "independently and the three results are packed into one combined " + + "canvas.", + IsTopIndexHighlight: true), + new GalleryImage( + LayeredMultipleDisconnectedClustersSvg, + "The same three clusters laid out directly by the layered algorithm", + "The companion direct-\"layered\" sibling of the diagram above: the same " + + "disconnected graph, laid out by the layered algorithm's own internal " + + "component packing rather than \"auto\"'s per-component routing, for " + + "comparison."), + new GalleryImage( + AutoAllIsolatedSvg, + "Five entirely isolated boxes packed by the auto algorithm", + "A graph of nothing but childless, edgeless singleton nodes: \"auto\" " + + "gathers every one of them into the shared bucket and routes the whole " + + "graph through the containment algorithm unchanged, taking its zero-copy " + + "fast path."), + ]), + new GallerySection( + "Containment packing heuristics", + "The containment algorithm derives its row-wrapping content-width budget from the " + + "packed boxes' shape, combining an area-based estimate with a column-count-based " + + "one so both a few large boxes and many small ones wrap into a balanced block.", + [ + new GalleryImage( + ContainmentManySmallWideBoxesSvg, + "Twelve small, wide boxes packed into a balanced multi-column block", + "Twelve identically-sized, wide boxes: the column-count-based content-width " + + "candidate keeps the containment algorithm from packing them into one " + + "long, narrow column, wrapping them into a balanced grid of columns " + + "instead.", + IsTopIndexHighlight: true), + ]), ]), - new GallerySection( - "Edge routing", - "Orthogonal connectors step around the boxes between their endpoints instead of cutting " - + "through them.", + new GalleryGroup( + "nested-hierarchy", + "Nested hierarchy", + "Parent/child containment and boundary-port delegation into nested children — what a " + + "container-with-children graph looks like once laid out and routed.", + "Parent/child containment and boundary-port delegation", [ - new GalleryImage( - OrthogonalObstacleSvg, - "Orthogonal edge routed around an obstacle", - "A connector routed orthogonally around an intervening container box."), + new GallerySection( + "Layout algorithms", + LayoutAlgorithmsIntro, + [ + new GalleryImage( + HierarchicalNestedSvg, + "Hierarchical nested diagram", + "A container node holding a nested child graph, with a cross-container " + + "edge."), + ]), + new GallerySection( + "Raster output", + RasterOutputIntro, + [ + new GalleryImage( + HierarchicalNestedPng, + "Hierarchical nested diagram as PNG", + "The hierarchical nested diagram rendered to a raster PNG image."), + ]), + new GallerySection( + "The auto meta-algorithm", + AutoMetaAlgorithmIntro, + [ + new GalleryImage( + AutoNestedRoutesHierarchicalSvg, + "A nested container routed to hierarchical alongside an unrelated isolated " + + "box", + "\"auto\" routes any component containing a container node through the " + + "hierarchical algorithm regardless of its size, while the unrelated " + + "isolated sibling is packed alongside it through the shared containment " + + "bucket."), + ]), + new GallerySection( + "Boundary and delegation ports", + "The hierarchical engine's support for boundary (delegation) ports: a container may " + + "expose a named port carrying BOTH an external and an internal label at one " + + "shared physical anchor on its boundary. An external approach edge from a sibling " + + "reaches the anchor from outside, while one or more internal delegation edges " + + "relay the connection inward to the container's nested children. The container " + + "and its children are laid out in one combined recursive pass, and every " + + "converging edge — external approach and internal delegation alike — is routed " + + "through the orthogonal corridor router onto that single shared anchor, with the " + + "external label reading outward and the internal label reading inward.", + [ + new GalleryImage( + BoundaryPortsShowcaseHorizontalSvg, + "A sibling joined to a container's boundary port delegating to two children", + "A rightward-flowing container exposes one boundary port on its left face " + + "carrying both a 'command' external label (reading outward) and a " + + "'dispatch' internal label (reading inward) at the same shared anchor. " + + "The external approach edge from the sibling and both internal delegation " + + "edges (internal fan-out to two nested children) are routed orthogonally " + + "onto that one anchor."), + new GalleryImage( + BoundaryPortsShowcaseVerticalSvg, + "Two siblings joined to a container's top-face boundary port and one child", + "The companion downward-flowing case: the boundary port anchors on the " + + "container's top face, with external fan-out (two sibling approach edges) " + + "both routed orthogonally onto the one shared anchor, which then " + + "delegates inward to the single nested child."), + new GalleryImage( + BoundaryPortsShowcaseDeepChainSvg, + "A boundary port delegating through a nested boundary port to a leaf", + "A three-level delegation chain: a sibling approaches an outer container's " + + "boundary port, which delegates inward to a nested container's own " + + "boundary port, which delegates again to the innermost leaf. Both " + + "boundary crossings carry an outward external and an inward internal " + + "label, and the whole chain is routed orthogonally in one combined " + + "recursive pass with no diagonal shortcut at either boundary.", + IsTopIndexHighlight: true), + ]), ]), - new GallerySection( - "Themes", - "One representative diagram rendered with each of the three built-in themes, showing how the " - + "theme controls colours, stroke, and corner style without touching the layout. These are " - + "rendered through the raster path to PNG so each carries a solid theme background.", + new GalleryGroup( + "parallel-edges-and-ports", + "Parallel edges and ports", + "Dense parallel connections between a small number of boxes, including named/boundary " + + "ports and edge-count-aware gap widening — the same small-box-count-many-edges shape " + + "compared across algorithms.", + "Many edges between few boxes, named/boundary ports", [ - new GalleryImage( - ThemeLightPng, - "Representative diagram in the light theme", - "The light theme, suited to on-screen viewing."), - new GalleryImage( - ThemeDarkPng, - "Representative diagram in the dark theme", - "The dark theme, suited to dark-mode viewing."), - new GalleryImage( - ThemePrintPng, - "Representative diagram in the print theme", - "The print theme, optimised for black-and-white output."), + new GallerySection( + "Parallel-edge routing regressions", + "Regression coverage specific to routing many parallel connectors around and " + + "between boxes: a small graph, laid out by the containment algorithm, that " + + "reproduces a specific connector-routing bug once fixed, kept as permanent " + + "visual and numeric evidence that it stays fixed.", + [ + 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.", + IsTopIndexHighlight: true), + ]), + new GallerySection( + "Parallel edges and named ports", + "The layered algorithm's Phase 1 flat-graph support for multiple parallel " + + "connectors between the same two boxes, and for named ports attached to a " + + "specific, labelled location on a node's boundary. Parallel edges either collapse " + + "to one rendered connector (the default) or each keep their own " + + "independently-routed line, selected with the MergeParallelEdges option; each " + + "node's ContentInset margins are auto-computed from its ports' measured label " + + "widths so port text never overlaps the box's own content.", + [ + new GalleryImage( + ParallelEdgesPreservedSvg, + "Three parallel connectors between the same two boxes, independently " + + "routed", + "MergeParallelEdges set to false: all three parallel connectors survive, " + + "each with its own label."), + new GalleryImage( + ParallelEdgesPreservedVerticalSvg, + "The same three parallel connectors on a downward-flowing pair of boxes", + "The companion vertical-flow case: with a downward Direction the three " + + "parallel connectors anchor on the boxes' top and bottom faces instead of " + + "their left and right faces, and each box's WIDTH (not height) auto-grows " + + "to fit the widened lane spacing, since PortDistributor spreads anchors " + + "on a top/bottom face horizontally."), + new GalleryImage( + ParallelEdgesMergedSvg, + "The same three parallel connectors collapsed to a single line", + "The default MergeParallelEdges (true): the three parallel connectors " + + "collapse to a single rendered line, and its midpoint label is omitted " + + "entirely (not any single surviving connector's label) since a reader " + + "could not tell which of the three collapsed connectors a kept label " + + "would have belonged to."), + new GalleryImage( + PortsShowcaseHorizontalSvg, + "A hub node with a named port on each of its left and right sides", + "Left/right named ports on a rightward-flowing hub node; the long left-side " + + "incoming label auto-computes a widened ContentInsetLeft margin, measured " + + "with the Skia-backed text measurer."), + new GalleryImage( + PortsShowcaseVerticalSvg, + "A hub node with a named port on each of its top and bottom sides", + "The companion top/bottom case: a downward-flowing hub node, whose ports " + + "anchor on its top and bottom faces instead."), + new GalleryImage( + PortsShowcaseMultiConnectorHorizontalSvg, + "A hub node with two named ports on each of its left and right sides", + "Same-face crowding with two independently-labelled ports per side (one " + + "deliberately long): PortDistributor spreads both anchors on each face " + + "without collapsing them onto one row, and the hub's title stays clear of " + + "both stacked rows on either side."), + new GalleryImage( + PortsShowcaseMultiConnectorVerticalSvg, + "A hub node with two named ports on each of its top and bottom sides", + "The companion top/bottom case: two ports per face spread horizontally " + + "instead of vertically, proving the same crowding and title-collision " + + "protection when PortDistributor works along the cross axis of a " + + "downward flow."), + new GalleryImage( + PortsShowcaseUnlabeledFanOutSvg, + "A titled hub node with several unlabeled ports fanning out to four other " + + "boxes", + "A face with several ports carrying no label or text at all still grows the " + + "box tall enough to keep them from bunching together — the growth floor " + + "applies unconditionally to any face with two or more anchors, not only " + + "labeled ones."), + ]), + new GallerySection( + "Edge-count gap widening", + "When many parallel connectors fan through the single gap between two side-by-side " + + "boxes, that gap is widened in proportion to the connector count so each " + + "connector gets its own orthogonal routing lane instead of being crushed into one " + + "narrow channel — the same corridor-width reservation the layered pipeline " + + "already makes between its columns, now applied to the containment packer's " + + "same-row pairs and to the hierarchical engine's side-by-side sibling containers.", + [ + new GalleryImage( + ContainmentParallelEdgesSideBySideSvg, + "Eight edges fanning through a widened containment gap", + "The containment algorithm packs two tall, compartment-bearing peer boxes " + + "side by side and widens the horizontal gap between them in proportion to " + + "the eight parallel connectors routed through it, so the connectors fan " + + "cleanly into distinct lanes rather than crowding a fixed-width channel."), + new GalleryImage( + HierarchicalParallelEdgesSideBySideSvg, + "Eight cross-container edges through a widened sibling gap", + "The hierarchical engine places two peer containers side by side and widens " + + "the gap between them for the eight cross-container connectors that fan " + + "child-to-child through it — spacing the per-scope leaf algorithm alone " + + "cannot reserve, because those edges never appear in the sized view it " + + "lays out."), + ]), ]), - new GallerySection( - "Raster output", - "The layout-algorithm diagrams above are rendered as SVG with the dark theme; here the same " - + "two diagrams are rendered through the SkiaSharp raster path to PNG, proving multi-format " - + "output.", + new GalleryGroup( + "appearance-and-themes", + "Appearance and themes", + "Not a topology grouping — box shape/keyword/compartments, shape-aware connector " + + "anchoring, and theme showcases, organized pragmatically by appearance rather than by " + + "topology, unlike the rest of this gallery.", + "Box shapes, keywords, compartments, and built-in themes", [ - new GalleryImage( - LayeredPipelinePng, - "Layered pipeline diagram as PNG", - "The layered pipeline rendered to a raster PNG image."), - new GalleryImage( - HierarchicalNestedPng, - "Hierarchical nested diagram as PNG", - "The hierarchical nested diagram rendered to a raster PNG image."), + new GallerySection( + "Box appearance", + "A node's Shape, Keyword, and Compartments properties select the box outline, an " + + "italicized keyword line, and labelled feature sections, all through the plain " + + "input graph model — no downstream renderer-specific code required. This is " + + "generic block-diagram notation; SysML is just one modeling language that uses " + + "it.", + [ + new GalleryImage( + BoxAppearanceSvg, + "A folder container with two boxes carrying a keyword, one also " + + "compartmented, joined by an edge", + "A folder container holding two boxes with a keyword line — one also with " + + "a labelled compartment — joined by a decorated edge."), + ]), + new GallerySection( + "Shape-aware connectors", + "A box's Shape can make its true outline diverge from its plain bounding rectangle " + + "— a folder's tab, a note's folded corner, a rounded rectangle's corners. The " + + "router keeps connectors off those non-connectable regions and projects each " + + "anchor down to the shape's actual drawn outline, so every connector visibly " + + "touches the shape it targets.", + [ + new GalleryImage( + FolderTopFaceAnchorSvg, + "An external node connected into a folder top face, clear of the tab", + "An edge approaching a folder container from above: the connector avoids " + + "the tab and anchors on the folder's recessed top edge instead of " + + "floating above it."), + new GalleryImage( + ShapeGallerySvg, + "One of each container shape side by side, each holding content", + "Every Shape value side by side, each with content appropriate to it: " + + "rectangle and rounded-rectangle boxes with a keyword and a compartment, " + + "a folder holding a nested child, and a note holding free-form text — " + + "every shape reserves enough space so its content never overlaps the tab " + + "or the folded corner.", + IsTopIndexHighlight: true), + ]), + new GallerySection( + "Themes", + "One representative diagram rendered with each of the three built-in themes, " + + "showing how the theme controls colours, stroke, and corner style without " + + "touching the layout. These are rendered through the raster path to PNG so each " + + "carries a solid theme background.", + [ + new GalleryImage( + ThemeLightPng, + "Representative diagram in the light theme", + "The light theme, suited to on-screen viewing."), + new GalleryImage( + ThemeDarkPng, + "Representative diagram in the dark theme", + "The dark theme, suited to dark-mode viewing."), + new GalleryImage( + ThemePrintPng, + "Representative diagram in the print theme", + "The print theme, optimised for black-and-white output."), + ]), ]), - new GallerySection( - "Box appearance", - "A node's Shape, Keyword, and Compartments properties select the box outline, an " - + "italicized keyword line, and labelled feature sections, all through the plain input " - + "graph model — no downstream renderer-specific code required. This is generic block-" - + "diagram notation; SysML is just one modeling language that uses it.", - [ - new GalleryImage( - BoxAppearanceSvg, - "A folder container with two boxes carrying a keyword, one also compartmented, joined by an edge", - "A folder container holding two boxes with a keyword line — one also with a labelled " - + "compartment — joined by a decorated edge."), - ]), - new GallerySection( - "Shape-aware connectors", - "A box's Shape can make its true outline diverge from its plain bounding rectangle — a " - + "folder's tab, a note's folded corner, a rounded rectangle's corners. The router keeps " - + "connectors off those non-connectable regions and projects each anchor down to the shape's " - + "actual drawn outline, so every connector visibly touches the shape it targets.", - [ - new GalleryImage( - FolderTopFaceAnchorSvg, - "An external node connected into a folder top face, clear of the tab", - "An edge approaching a folder container from above: the connector avoids the tab and " - + "anchors on the folder's recessed top edge instead of floating above it."), - new GalleryImage( - ShapeGallerySvg, - "One of each container shape side by side, each holding content", - "Every Shape value side by side, each with content appropriate to it: rectangle and " - + "rounded-rectangle boxes with a keyword and a compartment, a folder holding a " - + "nested child, and a note holding free-form text — every shape reserves enough " - + "space so its content never overlaps the tab or the folded corner."), - ]), - new GallerySection( - "Parallel edges and named ports", - "The layered algorithm's Phase 1 flat-graph support for multiple parallel connectors " - + "between the same two boxes, and for named ports attached to a specific, labelled " - + "location on a node's boundary. Parallel edges either collapse to one rendered connector " - + "(the default) or each keep their own independently-routed line, selected with the " - + "MergeParallelEdges option; each node's ContentInset margins are auto-computed from its " - + "ports' measured label widths so port text never overlaps the box's own content.", + new GalleryGroup( + "layout-regressions", + "Layout regressions", + "Kept as its own group per its organizing principle: bug history, not topology. " + + LayoutRegressionsIntro, + "Permanent visual proof that fixed layout bugs stay fixed", [ - new GalleryImage( - ParallelEdgesPreservedSvg, - "Three parallel connectors between the same two boxes, independently routed", - "MergeParallelEdges set to false: all three parallel connectors survive, each with " - + "its own label."), - new GalleryImage( - ParallelEdgesPreservedVerticalSvg, - "The same three parallel connectors on a downward-flowing pair of boxes", - "The companion vertical-flow case: with a downward Direction the three parallel " - + "connectors anchor on the boxes' top and bottom faces instead of their left and " - + "right faces, and each box's WIDTH (not height) auto-grows to fit the widened " - + "lane spacing, since PortDistributor spreads anchors on a top/bottom face " - + "horizontally."), - new GalleryImage( - ParallelEdgesMergedSvg, - "The same three parallel connectors collapsed to a single line", - "The default MergeParallelEdges (true): the three parallel connectors collapse to " - + "a single rendered line, and its midpoint label is omitted entirely (not any " - + "single surviving connector's label) since a reader could not tell which of the " - + "three collapsed connectors a kept label would have belonged to."), - new GalleryImage( - PortsShowcaseHorizontalSvg, - "A hub node with a named port on each of its left and right sides", - "Left/right named ports on a rightward-flowing hub node; the long left-side " - + "incoming label auto-computes a widened ContentInsetLeft margin, measured with " - + "the Skia-backed text measurer."), - new GalleryImage( - PortsShowcaseVerticalSvg, - "A hub node with a named port on each of its top and bottom sides", - "The companion top/bottom case: a downward-flowing hub node, whose ports anchor on " - + "its top and bottom faces instead."), - new GalleryImage( - PortsShowcaseMultiConnectorHorizontalSvg, - "A hub node with two named ports on each of its left and right sides", - "Same-face crowding with two independently-labelled ports per side (one " - + "deliberately long): PortDistributor spreads both anchors on each face without " - + "collapsing them onto one row, and the hub's title stays clear of both stacked " - + "rows on either side."), - new GalleryImage( - PortsShowcaseMultiConnectorVerticalSvg, - "A hub node with two named ports on each of its top and bottom sides", - "The companion top/bottom case: two ports per face spread horizontally instead of " - + "vertically, proving the same crowding and title-collision protection when " - + "PortDistributor works along the cross axis of a downward flow."), - new GalleryImage( - PortsShowcaseUnlabeledFanOutSvg, - "A titled hub node with several unlabeled ports fanning out to four other boxes", - "A face with several ports carrying no label or text at all still grows the box " - + "tall enough to keep them from bunching together — the growth floor applies " - + "unconditionally to any face with two or more anchors, not only labeled ones."), + new GallerySection( + "Isolated-node layer-gap fix", + LayoutRegressionsIntro, + [ + new GalleryImage( + IsolatedNodeLayerGapSvg, + "A hub column with an isolated node beside a routing-corridor dummy", + "Regression coverage for the isolated-node layer-gap fix: a genuinely " + + "isolated node (Isolated, zero edges) shares its layer with a dummy bend " + + "point created by an unrelated long edge (LongSource to LongTarget). The " + + "crossing minimizer now 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 GallerySection( - "Boundary and delegation ports", - "The hierarchical engine's support for boundary (delegation) ports: a container may expose " - + "a named port carrying BOTH an external and an internal label at one shared physical " - + "anchor on its boundary. An external approach edge from a sibling reaches the anchor from " - + "outside, while one or more internal delegation edges relay the connection inward to the " - + "container's nested children. The container and its children are laid out in one combined " - + "recursive pass, and every converging edge — external approach and internal delegation " - + "alike — is routed through the orthogonal corridor router onto that single shared anchor, " - + "with the external label reading outward and the internal label reading inward.", + new GalleryGroup( + "custom-rendering", + "Custom rendering", + "Every other group in this gallery shows what the layout engine itself produces: describe " + + "a graph, call LayoutEngine.Layout, render the result. This group is the deliberate " + + "exception. Its diagrams are hand-built LayoutTrees with boxes placed at explicit, " + + "hardcoded coordinates, rendered by calling ConnectorRouter and the renderers directly — " + + "no layout algorithm is involved at all. This is a legitimate, fully public capability " + + "(every type used here is public), so it earns its own honestly-labelled home rather than " + + "being mixed into a showcase of algorithm output. Expect rough edges: nothing here " + + "benefited from any algorithm's spacing or routing intelligence, and a person taking this " + + "path on their own diagrams should expect the same trade-off.", + "Hand-built LayoutTrees, bypassing the layout engine entirely", [ - new GalleryImage( - BoundaryPortsShowcaseHorizontalSvg, - "A sibling joined to a container's boundary port delegating to two children", - "A rightward-flowing container exposes one boundary port on its left face carrying " - + "both a 'command' external label (reading outward) and a 'dispatch' internal " - + "label (reading inward) at the same shared anchor. The external approach edge from " - + "the sibling and both internal delegation edges (internal fan-out to two nested " - + "children) are routed orthogonally onto that one anchor."), - new GalleryImage( - BoundaryPortsShowcaseVerticalSvg, - "Two siblings joined to a container's top-face boundary port and one child", - "The companion downward-flowing case: the boundary port anchors on the container's " - + "top face, with external fan-out (two sibling approach edges) both routed " - + "orthogonally onto the one shared anchor, which then delegates inward to the " - + "single nested child."), - new GalleryImage( - BoundaryPortsShowcaseDeepChainSvg, - "A boundary port delegating through a nested boundary port to a leaf", - "A three-level delegation chain: a sibling approaches an outer container's boundary " - + "port, which delegates inward to a nested container's own boundary port, which " - + "delegates again to the innermost leaf. Both boundary crossings carry an outward " - + "external and an inward internal label, and the whole chain is routed orthogonally " - + "in one combined recursive pass with no diagonal shortcut at either boundary."), + new GallerySection( + "Direct ConnectorRouter regression coverage", + "Regression coverage that requires an exact, hardcoded box arrangement no layout " + + "algorithm currently produces, kept as permanent visual and numeric evidence " + + "that a specific connector-routing bug stays fixed regardless of which algorithm " + + "(or future heuristic change) might eventually produce a similar arrangement.", + [ + new GalleryImage( + ParallelEdgesIntoCompartmentBoxSideBySideSvg, + "Nine edges sharing a narrow inter-box gap", + "Regression coverage for the OrthogonalEdgeRouter narrow-gap tangling fix: " + + "a small Source box placed explicitly beside a taller, nine-row-compartment " + + "Target box with a narrow gap between them, joined by nine hand-routed " + + "connectors (bypassing any layout algorithm, so this exact geometry stays " + + "covered regardless of which algorithm or future heuristic change might " + + "produce it). Before the fix, each successive parallel connector's own " + + "soft-obstacle avoidance offset its candidate lane a little further from " + + "the shared gap with no ceiling, eventually looping a route back behind " + + "the Source box it had already left; the router now clamps soft-obstacle " + + "lane candidates to the box pair's own envelope and penalizes (without " + + "blocking) any move that still leaves it. The router also now prices a " + + "connector transversally crossing another already-routed connector's " + + "trunk (OrthogonalEdgeRouter's SoftObstacleCrossingCost), so a connector " + + "with a genuinely bounded-cost non-crossing alternative takes it; this gap " + + "is saturated enough (nine connectors, one alternate lane) that one " + + "connector's own approach stub still crosses two others' trunks here, " + + "because that connector is routed before the two it later crosses and so " + + "cannot see them as obstacles yet — a known single-pass routing-order " + + "limitation, not a pricing gap, left as documented residual behavior " + + "rather than force a much wider routing-order change for this one " + + "saturated diagram."), + ]), ]), ]; } diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs index 4ed480e..93e4024 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -3,6 +3,7 @@ // using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout; namespace DemaConsulting.Rendering.Gallery; @@ -176,6 +177,59 @@ public static LayoutGraph ParallelEdgesIntoCompartmentBox() return graph; } + /// + /// Permanent visual regression coverage for the connector-router "coiled/looping" robustness + /// fix (OrthogonalEdgeRouter's soft-obstacle envelope clamp and envelope-departure cost), + /// built with explicit, hardcoded box positions rather than any layout algorithm. This deliberately + /// forces the exact geometry that exposed the bug — a small Source box beside a much + /// taller, nine-row-compartment Target box, separated by a narrow gap, joined by nine + /// distinct unmerged edges — regardless of which layout algorithm (or future heuristic change) + /// happens to produce a side-by-side arrangement for a similar graph. The companion + /// diagram exercises the same defect's vertical-stack + /// arrangement through the containment algorithm; this one exists so the narrow-gap, side-by-side + /// arrangement itself remains covered even if no algorithm currently produces it. Because it + /// bypasses entirely, it belongs only to the + /// custom-rendering gallery group, never to a group showcasing algorithm output. + /// + /// The theme the diagram will be rendered with, used to size the target box's compartment. + /// + /// A hand-built with the two boxes placed explicitly and the nine + /// connectors already routed via . + /// + public static LayoutTree ParallelEdgesIntoCompartmentBoxSideBySide(Theme theme) + { + var rowsCompartment = new LayoutCompartment( + "rows", + ["row1", "row2", "row3", "row4", "row5", "row6", "row7", "row8", "row9"]); + var titledHeight = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: false); + + var source = new LayoutBox(12, 12, 130, 50, "Source", 0, BoxShape.Rectangle, [], []); + var target = new LayoutBox( + 166, + 12, + 130, + titledHeight + TitledCompartmentHeight(theme, rowsCompartment), + "Target", + 0, + BoxShape.Rectangle, + [rowsCompartment], + []); + var boxes = new[] { source, target }; + + var connections = Enumerable.Range(0, 9) + .Select(_ => new Connection(source, target, EndMarkerStyle.FilledDiamond)) + .ToArray(); + var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); + + var nodes = new List(); + nodes.AddRange(boxes); + nodes.AddRange(lines); + + var width = target.X + target.Width + 12; + var height = Math.Max(source.Y + source.Height, target.Y + target.Height) + 12; + return new LayoutTree(width, height, nodes); + } + /// /// 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. @@ -848,6 +902,236 @@ public static LayoutGraph BoundaryPortsShowcaseDeepChain() return graph; } + /// + /// A small, fully-connected directed pipeline laid out via the layered algorithm, kept as a + /// visual baseline for comparison against the disconnected-graph diagrams in the same section: + /// this graph has a single connected component, so it never engages the layered pipeline's + /// internal component-packing path. + /// + /// A flat, fully-connected graph of four labelled boxes. + public static LayoutGraph LayeredRegressionBaseline() + { + var graph = new LayoutGraph(); + var start = AddLabelled(graph, "start", "Start"); + var middle = AddLabelled(graph, "middle", "Middle"); + var branch = AddLabelled(graph, "branch", "Branch"); + var end = AddLabelled(graph, "end", "End"); + + Connect(graph, "start-middle", start, middle); + Connect(graph, "middle-branch", middle, branch); + Connect(graph, "middle-end", middle, end); + + return graph; + } + + /// + /// One small connected cluster plus several unrelated singleton nodes, suited to the + /// "auto" meta-algorithm: the cluster routes through the layered algorithm while the + /// singletons are gathered into one shared bucket routed through the containment algorithm, then + /// both pieces are packed into a single combined canvas. + /// + /// A graph mixing one two-node cluster with three isolated singleton nodes. + public static LayoutGraph AutoClusterPlusIsolated() + { + var graph = new LayoutGraph(); + var a = AddLabelled(graph, "a", "A"); + var b = AddLabelled(graph, "b", "B"); + Connect(graph, "a-b", a, b); + + AddLabelled(graph, "solo1", "Solo1"); + AddLabelled(graph, "solo2", "Solo2"); + AddLabelled(graph, "solo3", "Solo3"); + + return graph; + } + + /// + /// Three independent two-node clusters with no edges between them, used both as the + /// "auto" multi-component showcase (each cluster routed and packed independently) and, + /// directly, as the "layered" sibling showing the same disconnected graph handled by the + /// layered algorithm's own internal component packing. + /// + /// A graph of three disconnected two-node clusters. + public static LayoutGraph MultipleDisconnectedClusters() + { + var graph = new LayoutGraph(); + var a1 = AddLabelled(graph, "a1", "A1"); + var a2 = AddLabelled(graph, "a2", "A2"); + Connect(graph, "a1-a2", a1, a2); + + var b1 = AddLabelled(graph, "b1", "B1"); + var b2 = AddLabelled(graph, "b2", "B2"); + Connect(graph, "b1-b2", b1, b2); + + var c1 = AddLabelled(graph, "c1", "C1"); + var c2 = AddLabelled(graph, "c2", "C2"); + Connect(graph, "c1-c2", c1, c2); + + return graph; + } + + /// + /// A graph of entirely isolated nodes (no edges, no children), suited to the "auto" + /// meta-algorithm's all-singleton fast path: every node is gathered into the shared bucket and + /// routed through the containment algorithm, unchanged, with no splitting or packing needed. + /// + /// A flat graph of five isolated labelled boxes. + public static LayoutGraph AutoAllIsolated() + { + var graph = new LayoutGraph(); + AddLabelled(graph, "n1", "N1"); + AddLabelled(graph, "n2", "N2"); + AddLabelled(graph, "n3", "N3"); + AddLabelled(graph, "n4", "N4"); + AddLabelled(graph, "n5", "N5"); + + return graph; + } + + /// + /// A nested container plus an unrelated isolated singleton, suited to the "auto" + /// meta-algorithm's routing rule that any component containing a container node is routed + /// through the hierarchical algorithm regardless of its size, while the unrelated singleton is + /// packed alongside it through the shared containment bucket. + /// + /// A graph mixing one two-level compound container with one isolated leaf node. + public static LayoutGraph AutoNestedRoutesHierarchical() + { + var graph = new LayoutGraph(); + + var group = graph.AddNode("group", 10, 10); + group.Label = "Group"; + var inner1 = AddLabelled(group.Children, "inner1", "Inner1"); + var inner2 = AddLabelled(group.Children, "inner2", "Inner2"); + Connect(group.Children, "inner1-inner2", inner1, inner2); + + AddLabelled(graph, "solo", "Solo"); + + return graph; + } + + /// + /// Twelve small, wide sibling boxes with no edges, suited to the containment algorithm's + /// column-count-based content-width candidate: without it, the area-based width estimate alone + /// would under-size the packing budget for this shape and pack the boxes into a single narrow + /// column instead of a balanced multi-column block. + /// + /// A flat graph of twelve labelled boxes, each 160 by 40 logical pixels. + public static LayoutGraph ContainmentManySmallWideBoxes() + { + var graph = new LayoutGraph(); + for (var i = 0; i < 12; i++) + { + var node = graph.AddNode($"box{i}", 160, 40); + node.Label = $"Box{i}"; + } + + return graph; + } + + /// + /// Coverage (and visual evidence) for the containment algorithm's edge-count-aware horizontal + /// gap widening: two equally-sized peer boxes, each carrying a nine-row compartment, joined by + /// eight distinct unmerged edges and packed side by side by the containment algorithm. + /// The boxes are deliberately tall and moderately wide so the algorithm's content-width budget + /// places them on the same row (rather than wrapping them into a vertical stack as the shorter + /// source/target pair does); the eight parallel + /// connectors must therefore fan through the single horizontal gap between them, which the packer + /// now widens in proportion to the edge count so the connectors get distinct routing lanes. + /// + /// + /// Unlike — a hand-built + /// with explicitly-placed boxes that never invokes any layout algorithm + /// — this diagram is a pure laid out by the real containment algorithm, + /// so it exercises the packer's same-row gap-widening path end to end. The box sizes were + /// empirically confirmed to pack side by side: two 100-wide boxes this tall satisfy the packer's + /// 2 * width + gap <= contentWidth same-row budget. + /// + /// + /// A flat graph suited to the containment algorithm: two tall, compartment-bearing peer boxes + /// joined by eight distinct parallel edges, sized to pack side by side. + /// + public static LayoutGraph ContainmentParallelEdgesSideBySide() + { + var theme = Themes.Dark; + var titledHeight = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: false); + + string[] rows = ["row1", "row2", "row3", "row4", "row5", "row6", "row7", "row8", "row9"]; + var boxHeight = titledHeight + TitledCompartmentHeight(theme, new LayoutCompartment("rows", rows)); + + var graph = new LayoutGraph(); + graph.Set(CoreOptions.MergeParallelEdges, false); + + var left = graph.AddNode("left", 100, boxHeight); + left.Label = "Left"; + left.Compartments = [new LayoutCompartment("rows", rows)]; + + var right = graph.AddNode("right", 100, boxHeight); + right.Label = "Right"; + right.Compartments = [new LayoutCompartment("rows", rows)]; + + for (var i = 0; i < 8; i++) + { + var edge = graph.AddEdge($"left-right-{i}", left, right); + edge.TargetEnd = EndMarkerStyle.FilledDiamond; + } + + return graph; + } + + /// + /// Coverage (and visual evidence) for the hierarchical algorithm's edge-count-aware + /// cross-container gap widening: two peer container boxes, each holding one compartment-bearing + /// child, placed side by side by the default leaf algorithm and joined by eight distinct + /// unmerged cross-container edges running child-to-child. The eight connectors must fan through + /// the single gap between the two containers; the hierarchical engine now widens that gap in + /// proportion to the cross-container edge count (a spacing the leaf algorithm alone cannot + /// reserve, because those edges never appear in the per-scope sized view it lays out) so the + /// connectors get distinct routing lanes. + /// + /// + /// The children are tall and narrow so the leaf algorithm's disconnected-component packing + /// places the two containers on the same row (side by side) rather than stacking them; the box + /// sizes were empirically confirmed to produce a side-by-side arrangement whose gap widens from + /// the un-widened baseline once the edge count exceeds one. This graph uses no boundary ports, so + /// it exercises the no-boundary-port placement path the widening pass operates on. + /// + /// + /// A two-level compound graph: two peer containers, each with one compartment-bearing child, + /// joined by eight parallel cross-container edges, sized to lay out side by side. + /// + public static LayoutGraph HierarchicalParallelEdgesSideBySide() + { + var theme = Themes.Dark; + var titledHeight = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: false); + + string[] rows = ["row1", "row2", "row3", "row4", "row5", "row6", "row7", "row8", "row9"]; + var childHeight = titledHeight + TitledCompartmentHeight(theme, new LayoutCompartment("rows", rows)); + + var graph = new LayoutGraph(); + graph.Set(CoreOptions.MergeParallelEdges, false); + + var left = graph.AddNode("left", 10, 10); + left.Label = "Left"; + var leftInner = left.Children.AddNode("leftInner", 90, childHeight); + leftInner.Label = "LeftInner"; + leftInner.Compartments = [new LayoutCompartment("rows", rows)]; + + var right = graph.AddNode("right", 10, 10); + right.Label = "Right"; + var rightInner = right.Children.AddNode("rightInner", 90, childHeight); + rightInner.Label = "RightInner"; + rightInner.Compartments = [new LayoutCompartment("rows", rows)]; + + for (var i = 0; i < 8; i++) + { + var edge = graph.AddEdge($"leftInner-rightInner-{i}", leftInner, rightInner); + edge.TargetEnd = EndMarkerStyle.FilledDiamond; + } + + return graph; + } + /// Computes the height of a titled compartment (a title row plus one row per line, plus the /// leading title-area gap and the trailing bottom gap), matching the renderer's own layout /// formula so the compartment content never overflows the box. diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryIndex.cs b/test/DemaConsulting.Rendering.Gallery/GalleryIndex.cs index bc0ff78..c6e9b33 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryIndex.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryIndex.cs @@ -2,44 +2,102 @@ // Copyright (c) DemaConsulting. All rights reserved. // +using System.Linq; using System.Text; namespace DemaConsulting.Rendering.Gallery; /// -/// Generates the browsable README.md index from the . The output -/// is kept markdownlint-clean (blank-line-separated headings and paragraphs, lines wrapped to 120 -/// columns, alt text on every image) so the committed index passes the repository's lint gate. +/// Generates the browsable README.md pages from the : a short +/// top-level index (docs/gallery/README.md) linking to one full page per topology-based group +/// (docs/gallery/<group>/README.md). The output is kept markdownlint-clean (blank-line- +/// separated headings and paragraphs, lines wrapped to 120 columns, alt text on every image) so the +/// committed pages pass the repository's lint gate. /// internal static class GalleryIndex { /// Maximum line length permitted by the repository markdownlint configuration (MD013). private const int MaxLineLength = 120; - /// Builds the full Markdown text of the gallery index from the catalog. - /// The complete README.md document, ending with a single newline. - public static string Build() + /// Builds the short, marketing-style top-level README.md index from the catalog. + /// The complete top-level README.md document, ending with a single newline. + public static string BuildTopIndex() { var builder = new StringBuilder(); AppendParagraph(builder, "# Rendering Gallery"); AppendParagraph( builder, - "This gallery showcases what the Rendering library can produce. Every image is generated " - + "by the gallery test project directly from the public API, so this page doubles as an " - + "end-to-end rendering smoke test."); + "See what the Rendering library can do with your own data. Every image below is " + + "generated by the gallery test project directly from the public API — this page " + + "doubles as an end-to-end rendering smoke test, not a set of hand-drawn mock-ups. " + + "Regenerate every page and image by running `./gallery.ps1` from the repository root."); AppendParagraph( builder, - "Regenerate this page and its images by running `./gallery.ps1` from the repository root."); + "Bring a graph shaped like yours and find its group below: each one takes the *same kind* " + + "of input data and lays it out with several of the bundled algorithms side by side, so " + + "you can see whether the automatic `auto` choice looks right for your case or whether " + + "you'd rather call a specific algorithm directly. The one exception is **Appearance and " + + "themes**, which is grouped by visual capability instead of input shape. **Layout " + + "regressions** is kept separate too, as a permanent record of bugs that were fixed and " + + "must stay fixed."); + AppendParagraph( + builder, + "Each group's own page has the full diagram set with detailed captions explaining what " + + "each one demonstrates and, where relevant, which bug it guards against. This page just " + + "points the way and shows a taste of each."); - foreach (var section in GalleryCatalog.Sections) + AppendParagraph(builder, "| Group | What it's about |"); + AppendParagraph(builder, "| --- | --- |"); + foreach (var group in GalleryCatalog.Groups) + { + builder + .Append($"| [{group.Title}]({group.Folder}/README.md) | {group.ShortSummary} |") + .Append('\n'); + } + + builder.Append('\n'); + + AppendParagraph(builder, "## A taste of what's inside"); + + var highlights = GalleryCatalog.Groups + .SelectMany(group => group.Sections + .SelectMany(section => section.Images) + .Where(image => image.IsTopIndexHighlight) + .Select(image => (group, image))); + foreach (var (group, image) in highlights) + { + var fileName = Path.GetFileName(image.FileName); + AppendParagraph(builder, $"![{image.Alt}]({group.Folder}/{fileName})"); + AppendParagraph( + builder, + $"{image.Caption} See [{group.Title}]({group.Folder}/README.md) for the full set."); + } + + // Collapse the trailing paragraph separator to a single terminating newline (MD047). + return builder.ToString().TrimEnd('\n') + "\n"; + } + + /// Builds one group's full README.md page text from its sections. + /// The group to render. + /// The complete group README.md document, ending with a single newline. + public static string BuildGroupPage(GalleryGroup group) + { + var builder = new StringBuilder(); + + AppendParagraph(builder, $"# {group.Title}"); + AppendParagraph(builder, group.Intro); + AppendParagraph(builder, "[Back to the gallery index](../README.md)"); + + foreach (var section in group.Sections) { AppendParagraph(builder, $"## {section.Title}"); AppendParagraph(builder, section.Intro); foreach (var image in section.Images) { - AppendParagraph(builder, $"![{image.Alt}]({image.FileName})"); + // Images live alongside this page's own README.md, so link by basename only. + AppendParagraph(builder, $"![{image.Alt}]({Path.GetFileName(image.FileName)})"); AppendParagraph(builder, image.Caption); } } @@ -49,15 +107,31 @@ public static string Build() } /// - /// Writes the generated index to README.md in the given directory. + /// Writes the top-level index and every group page under , + /// creating each group's subfolder as needed. /// - /// The directory that receives README.md. - /// The absolute path of the written index file. - public static string Write(string directory) + /// The gallery root directory (for example docs/gallery). + /// The absolute paths of every written README.md file, top-level index first. + public static IReadOnlyList WriteAll(string rootDirectory) { - var path = Path.Combine(directory, "README.md"); - File.WriteAllText(path, Build(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - return path; + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var written = new List(); + + var topIndexPath = Path.Combine(rootDirectory, "README.md"); + File.WriteAllText(topIndexPath, BuildTopIndex(), encoding); + written.Add(topIndexPath); + + foreach (var group in GalleryCatalog.Groups) + { + var groupDirectory = Path.Combine(rootDirectory, group.Folder); + Directory.CreateDirectory(groupDirectory); + + var groupPath = Path.Combine(groupDirectory, "README.md"); + File.WriteAllText(groupPath, BuildGroupPage(group), encoding); + written.Add(groupPath); + } + + return written; } /// diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryIndexTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryIndexTests.cs index a4168ee..8424010 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryIndexTests.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryIndexTests.cs @@ -5,7 +5,7 @@ namespace DemaConsulting.Rendering.Gallery; /// -/// Exercises the README.md index generator. The generated Markdown is validated on every +/// Exercises the README.md page generators. The generated Markdown is validated on every /// run (a smoke test of the generator), but only written to disk in "regenerate the committed /// showcase" mode — selected by RENDERING_GALLERY_DIR — so ordinary test runs never write a /// Markdown file into the build output where the lint gate would pick it up. @@ -13,39 +13,118 @@ namespace DemaConsulting.Rendering.Gallery; public sealed class GalleryIndexTests { /// - /// Proves the generated index is a valid, browsable document that references every catalogued - /// image and section, and — when in gallery mode — is written next to the images. + /// Proves the generated top-level index is a valid, browsable document that references every + /// group's title, folder link, and short table summary. /// [Fact] - public void Gallery_Index_ReferencesEveryImageAndSection() + public void Gallery_TopIndex_ReferencesEveryGroup() { - // Arrange / Act: generate the index from the catalog. - var markdown = GalleryIndex.Build(); + // Arrange / Act: generate the top-level index from the catalog. + var markdown = GalleryIndex.BuildTopIndex(); // Assert: the document is well-formed and complete. Assert.StartsWith("# Rendering Gallery", markdown, StringComparison.Ordinal); Assert.EndsWith("\n", markdown, StringComparison.Ordinal); + Assert.Contains("| Group | What it's about |", markdown, StringComparison.Ordinal); - foreach (var section in GalleryCatalog.Sections) + foreach (var group in GalleryCatalog.Groups) { - Assert.Contains($"## {section.Title}", markdown, StringComparison.Ordinal); - foreach (var image in section.Images) + Assert.Contains( + $"| [{group.Title}]({group.Folder}/README.md) | {group.ShortSummary} |", + markdown, + StringComparison.Ordinal); + } + + AssertNoLineExceedsLintWidth(markdown); + } + + /// + /// Proves only images explicitly flagged appear + /// on the top-level index — opt-in by design, so a deliberate contrast/baseline image can never + /// surface there bare and out of context by accident, the way it could when the index used to + /// auto-pick the first non-excluded image per group. + /// + [Fact] + public void Gallery_TopIndex_ShowsOnlyExplicitlyHighlightedImages() + { + // Arrange / Act: generate the top-level index and the connectivity-and-clusters group page. + var topIndex = GalleryIndex.BuildTopIndex(); + var group = GalleryCatalog.Groups.First(g => g.Folder == "connectivity-and-clusters"); + var groupPage = GalleryIndex.BuildGroupPage(group); + + // Assert: a highlighted image appears on the index, using its own real caption. + var highlighted = group.Sections + .SelectMany(section => section.Images) + .First(image => image.IsTopIndexHighlight); + Assert.Contains(Path.GetFileName(highlighted.FileName), topIndex, StringComparison.Ordinal); + + // Assert: a non-highlighted image (the deliberate connected-graph contrast baseline) is never a + // top-index thumbnail, even though it still appears on the group's own full page. + var baselineFileName = Path.GetFileName(GalleryCatalog.LayeredRegressionBaselineSvg); + Assert.DoesNotContain(baselineFileName, topIndex, StringComparison.Ordinal); + Assert.Contains(baselineFileName, groupPage, StringComparison.Ordinal); + } + + /// + /// Proves each group's generated page is a valid, browsable document that references every one + /// of its sections' titles and images. + /// + [Fact] + public void Gallery_GroupPages_ReferenceEveryImageAndSection() + { + foreach (var group in GalleryCatalog.Groups) + { + // Arrange / Act: generate the group page from the catalog. + var markdown = GalleryIndex.BuildGroupPage(group); + + // Assert: the document is well-formed and complete. + Assert.StartsWith($"# {group.Title}", markdown, StringComparison.Ordinal); + Assert.EndsWith("\n", markdown, StringComparison.Ordinal); + + foreach (var section in group.Sections) { - Assert.Contains($"![{image.Alt}]({image.FileName})", markdown, StringComparison.Ordinal); + Assert.Contains($"## {section.Title}", markdown, StringComparison.Ordinal); + foreach (var image in section.Images) + { + var fileName = Path.GetFileName(image.FileName); + Assert.Contains($"![{image.Alt}]({fileName})", markdown, StringComparison.Ordinal); + } } + + AssertNoLineExceedsLintWidth(markdown); } + } - // No line exceeds the 120-column markdownlint limit. - foreach (var line in markdown.Split('\n')) + /// + /// In gallery mode, proves writes the top-level index and + /// every group's README.md alongside the images, each a non-empty file. + /// + [Fact] + public void Gallery_WriteAll_WritesTopIndexAndEveryGroupPage() + { + if (!GalleryOutput.IsGalleryMode) { - Assert.True(line.Length <= 120, $"Index line exceeds 120 columns: {line}"); + return; } - // In gallery mode, persist the index alongside the generated images and confirm it exists. - if (GalleryOutput.IsGalleryMode) + var written = GalleryIndex.WriteAll(GalleryOutput.ResolveDirectory()); + + // One top-level index plus one page per group. + Assert.Equal(GalleryCatalog.Groups.Count + 1, written.Count); + + foreach (var path in written) { - var path = GalleryIndex.Write(GalleryOutput.ResolveDirectory()); - Assert.True(File.Exists(path), $"Expected gallery index to be written: {path}"); + Assert.True(File.Exists(path), $"Expected gallery page to be written: {path}"); + Assert.True(new FileInfo(path).Length > 0, $"Expected gallery page to be non-empty: {path}"); + } + } + + /// Asserts that no line in exceeds the 120-column markdownlint limit. + private static void AssertNoLineExceedsLintWidth(string markdown) + { + foreach (var line in markdown.Split('\n')) + { + Assert.True(line.Length <= 120, $"Index line exceeds 120 columns: {line}"); } } } diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs index e752f67..baf9806 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs @@ -22,9 +22,11 @@ public sealed class GalleryShowcaseTests [Fact] public void Gallery_LayeredPipeline_RendersSvg() { + // Arrange var graph = GalleryDiagrams.LayeredPipeline(); graph.Set(CoreOptions.Algorithm, "layered"); + // Act / Assert GalleryWriter.Svg( GalleryCatalog.LayeredPipelineSvg, graph, @@ -40,9 +42,11 @@ public void Gallery_LayeredPipeline_RendersSvg() [Fact] public void Gallery_IsolatedNodeLayerGap_RendersSvg() { + // Arrange var graph = GalleryDiagrams.IsolatedNodeLayerGap(); graph.Set(CoreOptions.Algorithm, "layered"); + // Act / Assert GalleryWriter.Svg( GalleryCatalog.IsolatedNodeLayerGapSvg, graph, @@ -57,15 +61,34 @@ public void Gallery_IsolatedNodeLayerGap_RendersSvg() [Fact] public void Gallery_ParallelEdgesIntoCompartmentBox_RendersSvg() { + // Arrange var graph = GalleryDiagrams.ParallelEdgesIntoCompartmentBox(); graph.Set(CoreOptions.Algorithm, "containment"); + // Act / Assert GalleryWriter.Svg( GalleryCatalog.ParallelEdgesIntoCompartmentBoxSvg, graph, Themes.Dark); } + /// + /// Renders the parallel-edges-into-compartment-box-side-by-side regression diagram to SVG, + /// proving ConnectorRouter clamps soft-obstacle lane candidates to the Source/Target box + /// pair's own envelope (and penalizes, without blocking, any move that still leaves it) so nine + /// parallel connectors routed across a narrow inter-box gap never loop a route back behind the + /// box it already left. + /// + [Fact] + public void Gallery_ParallelEdgesIntoCompartmentBoxSideBySide_RendersSvg() + { + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.ParallelEdgesIntoCompartmentBoxSideBySideSvg, + GalleryDiagrams.ParallelEdgesIntoCompartmentBoxSideBySide(Themes.Dark), + Themes.Dark); + } + /// /// Renders the containment-packed diagram to SVG, proving the containment algorithm packs /// sibling boxes into a valid document. @@ -73,9 +96,11 @@ public void Gallery_ParallelEdgesIntoCompartmentBox_RendersSvg() [Fact] public void Gallery_ContainmentPacked_RendersSvg() { + // Arrange var graph = GalleryDiagrams.ContainmentPacked(); graph.Set(CoreOptions.Algorithm, "containment"); + // Act / Assert GalleryWriter.Svg( GalleryCatalog.ContainmentPackedSvg, graph, @@ -89,6 +114,7 @@ public void Gallery_ContainmentPacked_RendersSvg() [Fact] public void Gallery_HierarchicalNested_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.HierarchicalNestedSvg, GalleryDiagrams.HierarchicalNested(), @@ -102,6 +128,7 @@ public void Gallery_HierarchicalNested_RendersSvg() [Fact] public void Gallery_OrthogonalObstacle_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.OrthogonalObstacleSvg, GalleryDiagrams.OrthogonalObstacle(), @@ -115,10 +142,12 @@ public void Gallery_OrthogonalObstacle_RendersSvg() [Fact] public void Gallery_DirectionRight_RendersSvg() { + // Arrange var graph = GalleryDiagrams.DirectionShowcase(); graph.Set(CoreOptions.Algorithm, "layered"); graph.Set(CoreOptions.Direction, LayoutFlowDirection.Right); + // Act / Assert GalleryWriter.Svg( GalleryCatalog.DirectionRightSvg, graph, @@ -132,10 +161,12 @@ public void Gallery_DirectionRight_RendersSvg() [Fact] public void Gallery_DirectionDown_RendersSvg() { + // Arrange var graph = GalleryDiagrams.DirectionShowcase(); graph.Set(CoreOptions.Algorithm, "layered"); graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + // Act / Assert GalleryWriter.Svg( GalleryCatalog.DirectionDownSvg, graph, @@ -150,6 +181,7 @@ public void Gallery_DirectionDown_RendersSvg() [Fact] public void Gallery_MixedDirectionNested_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.MixedDirectionNestedSvg, GalleryDiagrams.MixedDirectionNested(), @@ -163,6 +195,7 @@ public void Gallery_MixedDirectionNested_RendersSvg() [Fact] public void Gallery_ThemeLight_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.ThemeLightPng, GalleryDiagrams.ThemeShowcase(), @@ -176,6 +209,7 @@ public void Gallery_ThemeLight_RendersPng() [Fact] public void Gallery_ThemeDark_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.ThemeDarkPng, GalleryDiagrams.ThemeShowcase(), @@ -189,6 +223,7 @@ public void Gallery_ThemeDark_RendersPng() [Fact] public void Gallery_ThemePrint_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.ThemePrintPng, GalleryDiagrams.ThemeShowcase(), @@ -202,9 +237,11 @@ public void Gallery_ThemePrint_RendersPng() [Fact] public void Gallery_LayeredPipeline_RendersPng() { + // Arrange var graph = GalleryDiagrams.LayeredPipeline(); graph.Set(CoreOptions.Algorithm, "layered"); + // Act / Assert GalleryWriter.Png( GalleryCatalog.LayeredPipelinePng, graph, @@ -218,6 +255,7 @@ public void Gallery_LayeredPipeline_RendersPng() [Fact] public void Gallery_HierarchicalNested_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.HierarchicalNestedPng, GalleryDiagrams.HierarchicalNested(), @@ -233,6 +271,7 @@ public void Gallery_HierarchicalNested_RendersPng() [Fact] public void Gallery_BoxAppearance_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.BoxAppearanceSvg, GalleryDiagrams.BoxAppearance(), @@ -246,6 +285,7 @@ public void Gallery_BoxAppearance_RendersSvg() [Fact] public void Gallery_FolderTopFaceAnchor_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.FolderTopFaceAnchorSvg, GalleryDiagrams.FolderTopFaceAnchor(), @@ -260,6 +300,7 @@ public void Gallery_FolderTopFaceAnchor_RendersSvg() [Fact] public void Gallery_ShapeGallery_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.ShapeGallerySvg, GalleryDiagrams.ShapeGallery(), @@ -275,6 +316,7 @@ public void Gallery_ShapeGallery_RendersSvg() [Fact] public void Gallery_ParallelEdgesPreserved_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.ParallelEdgesPreservedSvg, GalleryDiagrams.ParallelEdgesPreserved(), @@ -290,6 +332,7 @@ public void Gallery_ParallelEdgesPreserved_RendersSvg() [Fact] public void Gallery_ParallelEdgesMerged_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.ParallelEdgesMergedSvg, GalleryDiagrams.ParallelEdgesMerged(), @@ -306,6 +349,7 @@ public void Gallery_ParallelEdgesMerged_RendersSvg() [Fact] public void Gallery_ParallelEdgesPreservedVertical_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.ParallelEdgesPreservedVerticalSvg, GalleryDiagrams.ParallelEdgesPreservedVertical(), @@ -321,6 +365,7 @@ public void Gallery_ParallelEdgesPreservedVertical_RendersSvg() [Fact] public void Gallery_PortsShowcaseHorizontal_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.PortsShowcaseHorizontalSvg, GalleryDiagrams.PortsShowcaseHorizontal(), @@ -335,6 +380,7 @@ public void Gallery_PortsShowcaseHorizontal_RendersSvg() [Fact] public void Gallery_PortsShowcaseVertical_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.PortsShowcaseVerticalSvg, GalleryDiagrams.PortsShowcaseVertical(), @@ -350,6 +396,7 @@ public void Gallery_PortsShowcaseVertical_RendersSvg() [Fact] public void Gallery_PortsShowcaseMultiConnectorHorizontal_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.PortsShowcaseMultiConnectorHorizontalSvg, GalleryDiagrams.PortsShowcaseMultiConnectorHorizontal(), @@ -365,6 +412,7 @@ public void Gallery_PortsShowcaseMultiConnectorHorizontal_RendersSvg() [Fact] public void Gallery_PortsShowcaseMultiConnectorVertical_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.PortsShowcaseMultiConnectorVerticalSvg, GalleryDiagrams.PortsShowcaseMultiConnectorVertical(), @@ -379,6 +427,7 @@ public void Gallery_PortsShowcaseMultiConnectorVertical_RendersSvg() [Fact] public void Gallery_PortsShowcaseUnlabeledFanOut_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.PortsShowcaseUnlabeledFanOutSvg, GalleryDiagrams.PortsShowcaseUnlabeledFanOut(), @@ -394,6 +443,7 @@ public void Gallery_PortsShowcaseUnlabeledFanOut_RendersSvg() [Fact] public void Gallery_BoundaryPortsShowcaseHorizontal_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.BoundaryPortsShowcaseHorizontalSvg, GalleryDiagrams.BoundaryPortsShowcaseHorizontal(), @@ -409,6 +459,7 @@ public void Gallery_BoundaryPortsShowcaseHorizontal_RendersSvg() [Fact] public void Gallery_BoundaryPortsShowcaseVertical_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.BoundaryPortsShowcaseVerticalSvg, GalleryDiagrams.BoundaryPortsShowcaseVertical(), @@ -424,6 +475,7 @@ public void Gallery_BoundaryPortsShowcaseVertical_RendersSvg() [Fact] public void Gallery_BoundaryPortsShowcaseDeepChain_RendersSvg() { + // Act / Assert GalleryWriter.Svg( GalleryCatalog.BoundaryPortsShowcaseDeepChainSvg, GalleryDiagrams.BoundaryPortsShowcaseDeepChain(), @@ -437,6 +489,7 @@ public void Gallery_BoundaryPortsShowcaseDeepChain_RendersSvg() [Fact] public void Gallery_PortsShowcaseHorizontal_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.PortsShowcaseHorizontalPng, GalleryDiagrams.PortsShowcaseHorizontal(), @@ -450,6 +503,7 @@ public void Gallery_PortsShowcaseHorizontal_RendersPng() [Fact] public void Gallery_PortsShowcaseVertical_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.PortsShowcaseVerticalPng, GalleryDiagrams.PortsShowcaseVertical(), @@ -463,6 +517,7 @@ public void Gallery_PortsShowcaseVertical_RendersPng() [Fact] public void Gallery_PortsShowcaseMultiConnectorHorizontal_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.PortsShowcaseMultiConnectorHorizontalPng, GalleryDiagrams.PortsShowcaseMultiConnectorHorizontal(), @@ -476,6 +531,7 @@ public void Gallery_PortsShowcaseMultiConnectorHorizontal_RendersPng() [Fact] public void Gallery_PortsShowcaseMultiConnectorVertical_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.PortsShowcaseMultiConnectorVerticalPng, GalleryDiagrams.PortsShowcaseMultiConnectorVertical(), @@ -489,6 +545,7 @@ public void Gallery_PortsShowcaseMultiConnectorVertical_RendersPng() [Fact] public void Gallery_PortsShowcaseUnlabeledFanOut_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.PortsShowcaseUnlabeledFanOutPng, GalleryDiagrams.PortsShowcaseUnlabeledFanOut(), @@ -502,6 +559,7 @@ public void Gallery_PortsShowcaseUnlabeledFanOut_RendersPng() [Fact] public void Gallery_ParallelEdgesMerged_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.ParallelEdgesMergedPng, GalleryDiagrams.ParallelEdgesMerged(), @@ -515,6 +573,7 @@ public void Gallery_ParallelEdgesMerged_RendersPng() [Fact] public void Gallery_ParallelEdgesPreserved_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.ParallelEdgesPreservedPng, GalleryDiagrams.ParallelEdgesPreserved(), @@ -528,6 +587,7 @@ public void Gallery_ParallelEdgesPreserved_RendersPng() [Fact] public void Gallery_ParallelEdgesPreservedVertical_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.ParallelEdgesPreservedVerticalPng, GalleryDiagrams.ParallelEdgesPreservedVertical(), @@ -541,6 +601,7 @@ public void Gallery_ParallelEdgesPreservedVertical_RendersPng() [Fact] public void Gallery_BoundaryPortsShowcaseHorizontal_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.BoundaryPortsShowcaseHorizontalPng, GalleryDiagrams.BoundaryPortsShowcaseHorizontal(), @@ -554,6 +615,7 @@ public void Gallery_BoundaryPortsShowcaseHorizontal_RendersPng() [Fact] public void Gallery_BoundaryPortsShowcaseVertical_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.BoundaryPortsShowcaseVerticalPng, GalleryDiagrams.BoundaryPortsShowcaseVertical(), @@ -567,9 +629,185 @@ public void Gallery_BoundaryPortsShowcaseVertical_RendersPng() [Fact] public void Gallery_BoundaryPortsShowcaseDeepChain_RendersPng() { + // Act / Assert GalleryWriter.Png( GalleryCatalog.BoundaryPortsShowcaseDeepChainPng, GalleryDiagrams.BoundaryPortsShowcaseDeepChain(), Themes.Dark); } + + /// + /// Renders a small, fully-connected pipeline laid out by the layered algorithm to SVG, kept as + /// a visual baseline for comparison against the disconnected-graph diagrams in the same + /// "auto" meta-algorithm section. + /// + [Fact] + public void Gallery_LayeredRegressionBaseline_RendersSvg() + { + // Arrange + var graph = GalleryDiagrams.LayeredRegressionBaseline(); + graph.Set(CoreOptions.Algorithm, "layered"); + + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.LayeredRegressionBaselineSvg, + graph, + Themes.Dark); + } + + /// + /// Renders one connected cluster packed alongside three isolated singleton boxes to SVG, + /// proving the "auto" meta-algorithm routes the cluster through the layered algorithm and + /// gathers the singletons into one shared bucket routed through the containment algorithm, + /// then packs both pieces into one canvas. + /// + [Fact] + public void Gallery_AutoClusterPlusIsolated_RendersSvg() + { + // Arrange + var graph = GalleryDiagrams.AutoClusterPlusIsolated(); + graph.Set(CoreOptions.Algorithm, "auto"); + + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.AutoClusterPlusIsolatedSvg, + graph, + Themes.Dark); + } + + /// + /// Renders three disconnected two-node clusters, each routed and packed independently, to SVG, + /// proving the "auto" meta-algorithm treats each connected component as its own layered-routed + /// piece before packing all three results into one combined canvas. + /// + [Fact] + public void Gallery_AutoMultipleDisconnectedClusters_RendersSvg() + { + // Arrange + var graph = GalleryDiagrams.MultipleDisconnectedClusters(); + graph.Set(CoreOptions.Algorithm, "auto"); + + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.AutoMultipleDisconnectedClustersSvg, + graph, + Themes.Dark); + } + + /// + /// Renders the same three disconnected clusters laid out directly by the layered algorithm to + /// SVG — the companion direct-"layered" sibling of + /// , proving the layered + /// algorithm's own internal component packing handles the same disconnected graph. + /// + [Fact] + public void Gallery_LayeredMultipleDisconnectedClusters_RendersSvg() + { + // Arrange + var graph = GalleryDiagrams.MultipleDisconnectedClusters(); + graph.Set(CoreOptions.Algorithm, "layered"); + + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.LayeredMultipleDisconnectedClustersSvg, + graph, + Themes.Dark); + } + + /// + /// Renders five entirely isolated boxes packed by the "auto" algorithm to SVG, proving that a + /// graph of nothing but childless, edgeless singletons is gathered into the shared bucket and + /// routed through the containment algorithm unchanged, taking its zero-copy fast path. + /// + [Fact] + public void Gallery_AutoAllIsolated_RendersSvg() + { + // Arrange + var graph = GalleryDiagrams.AutoAllIsolated(); + graph.Set(CoreOptions.Algorithm, "auto"); + + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.AutoAllIsolatedSvg, + graph, + Themes.Dark); + } + + /// + /// Renders a nested container routed to hierarchical alongside an unrelated isolated box to + /// SVG, proving the "auto" meta-algorithm routes any component containing a container node + /// through the hierarchical algorithm regardless of its size, while the unrelated isolated + /// sibling is packed alongside it through the shared containment bucket. + /// + [Fact] + public void Gallery_AutoNestedRoutesHierarchical_RendersSvg() + { + // Arrange + var graph = GalleryDiagrams.AutoNestedRoutesHierarchical(); + graph.Set(CoreOptions.Algorithm, "auto"); + + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.AutoNestedRoutesHierarchicalSvg, + graph, + Themes.Dark); + } + + /// + /// Renders twelve small, wide boxes packed by the containment algorithm to SVG, proving the + /// column-count-based content-width candidate keeps the algorithm from packing them into one + /// long, narrow column, wrapping them into a balanced grid of columns instead. + /// + [Fact] + public void Gallery_ContainmentManySmallWideBoxes_RendersSvg() + { + // Arrange + var graph = GalleryDiagrams.ContainmentManySmallWideBoxes(); + graph.Set(CoreOptions.Algorithm, "containment"); + + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.ContainmentManySmallWideBoxesSvg, + graph, + Themes.Dark); + } + + /// + /// Renders the containment edge-count gap-widening diagram to SVG, proving the containment + /// algorithm packs two tall peer boxes side by side and widens the horizontal gap between them in + /// proportion to the eight parallel connectors routed through it, so the connectors fan into + /// distinct lanes. + /// + [Fact] + public void Gallery_ContainmentParallelEdgesSideBySide_RendersSvg() + { + // Arrange + var graph = GalleryDiagrams.ContainmentParallelEdgesSideBySide(); + graph.Set(CoreOptions.Algorithm, "containment"); + + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.ContainmentParallelEdgesSideBySideSvg, + graph, + Themes.Dark); + } + + /// + /// Renders the hierarchical cross-container edge-count gap-widening diagram to SVG, proving the + /// hierarchical engine places two peer containers side by side and widens the gap between them + /// for the eight cross-container connectors that fan child-to-child through it. + /// + [Fact] + public void Gallery_HierarchicalParallelEdgesSideBySide_RendersSvg() + { + // Arrange + var graph = GalleryDiagrams.HierarchicalParallelEdgesSideBySide(); + graph.Set(CoreOptions.Algorithm, "hierarchical"); + + // Act / Assert + GalleryWriter.Svg( + GalleryCatalog.HierarchicalParallelEdgesSideBySideSvg, + graph, + Themes.Dark); + } } diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs b/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs index c25228f..350c9c9 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs @@ -41,6 +41,31 @@ public static void Svg(string fileName, LayoutGraph graph, Theme theme) { var tree = LayoutEngine.Layout(graph); var path = Path.Combine(GalleryOutput.ResolveDirectory(), fileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + using (var stream = File.Create(path)) + { + SvgRenderer.Render(tree, new RenderOptions(theme), stream); + } + + AssertValidSvg(path); + } + + /// + /// Renders a pre-built directly to as SVG, + /// bypassing entirely, and asserts the result is a + /// well-formed SVG document. Every other gallery group exists to show what the layout engine + /// itself produces, so this overload must be used only by diagrams belonging to the + /// custom-rendering gallery group — the one group whose whole point is a hand-built box + /// arrangement, honestly labelled as bypassing the engine, rather than algorithm output. + /// + /// Stable output filename (for example custom-rendering/my-diagram.svg). + /// The already-laid-out tree to render. + /// The theme to render with. + public static void Svg(string fileName, LayoutTree tree, Theme theme) + { + var path = Path.Combine(GalleryOutput.ResolveDirectory(), fileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); using (var stream = File.Create(path)) { @@ -65,6 +90,7 @@ public static void Png(string fileName, LayoutGraph graph, Theme theme) { var tree = LayoutEngine.Layout(graph); var path = Path.Combine(GalleryOutput.ResolveDirectory(), fileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); using (var stream = File.Create(path)) { diff --git a/test/DemaConsulting.Rendering.Layout.Tests/AutoLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/AutoLayoutAlgorithmTests.cs new file mode 100644 index 0000000..a45c642 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/AutoLayoutAlgorithmTests.cs @@ -0,0 +1,403 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Layout.Tests; + +/// +/// Tests for the bundled , exercising its component routing rules, +/// its zero-copy fast path for a single group, and its splitting-and-packing path for genuine +/// multi-group graphs. +/// +public sealed class AutoLayoutAlgorithmTests +{ + /// + /// Proves that the algorithm advertises the stable "auto" identifier. + /// + [Fact] + public void Id_IsAuto() + { + // Act / Assert + Assert.Equal("auto", new AutoLayoutAlgorithm().Id); + } + + /// + /// Proves that Apply(LayoutGraph) rejects a graph. + /// + [Fact] + public void Apply_NullGraph_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => new AutoLayoutAlgorithm().Apply(null!)); + } + + /// + /// Proves that ApplyCore(LayoutGraph, LayoutOptions) rejects a + /// options. + /// + [Fact] + public void ApplyCore_NullOptions_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => new AutoLayoutAlgorithm().ApplyCore(new LayoutGraph(), null!)); + } + + /// + /// Proves that an empty graph produces an empty, but valid, placed tree instead of throwing. + /// + [Fact] + public void Apply_EmptyGraph_ReturnsEmptyTree() + { + // Arrange + var graph = new LayoutGraph(); + + // Act + var tree = new AutoLayoutAlgorithm().Apply(graph); + + // Assert + Assert.Empty(tree.Nodes.OfType()); + } + + /// + /// Proves that a single fully-connected component routes entirely through the layered algorithm, + /// taking the zero-copy fast path (byte-identical to invoking + /// directly on the same graph). + /// + [Fact] + public void Apply_SingleConnectedComponent_MatchesLayeredAlgorithmDirectly() + { + // Arrange + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 80, 40); + var b = graph.AddNode("b", 80, 40); + var c = graph.AddNode("c", 80, 40); + a.Label = "A"; + b.Label = "B"; + c.Label = "C"; + graph.AddEdge("a-b", a, b); + graph.AddEdge("b-c", b, c); + + // Act + var autoTree = new AutoLayoutAlgorithm().Apply(graph); + var layeredTree = new LayeredLayoutAlgorithm().Apply(graph); + + // Assert + Assert.Equal(DumpTree(layeredTree), DumpTree(autoTree)); + } + + /// + /// Proves that a graph containing only childless, edgeless singleton nodes routes entirely + /// through the containment algorithm, taking the zero-copy fast path. + /// + [Fact] + public void Apply_AllIsolatedSingletons_MatchesContainmentAlgorithmDirectly() + { + // Arrange + var graph = new LayoutGraph(); + graph.AddNode("a", 80, 40); + graph.AddNode("b", 80, 40); + graph.AddNode("c", 80, 40); + + // Act + var autoTree = new AutoLayoutAlgorithm().Apply(graph); + var containmentTree = new ContainmentLayoutAlgorithm().Apply(graph); + + // Assert + Assert.Equal(DumpTree(containmentTree), DumpTree(autoTree)); + } + + /// + /// Proves that a single isolated node with children is still routed to the hierarchical + /// algorithm — a container needs hierarchical recursion even when nothing else references it — + /// taking the zero-copy fast path. + /// + [Fact] + public void Apply_IsolatedNodeWithChildren_MatchesHierarchicalAlgorithmDirectly() + { + // Arrange + var graph = new LayoutGraph(); + var group = graph.AddNode("group", 10, 10); + group.Label = "Group"; + var inner1 = group.Children.AddNode("c1", 80, 40); + var inner2 = group.Children.AddNode("c2", 80, 40); + group.Children.AddEdge("c1-c2", inner1, inner2); + + // Act + var autoTree = new AutoLayoutAlgorithm().Apply(graph); + var hierarchicalTree = new HierarchicalLayoutAlgorithm().Apply(graph); + + // Assert + Assert.Equal(DumpTree(hierarchicalTree), DumpTree(autoTree)); + } + + /// + /// Proves that a nested container two levels deep still routes the whole graph to hierarchical + /// (rather than incorrectly treating the root as a flat, childless component). + /// + [Fact] + public void Apply_NestedContainers_RoutesToHierarchical() + { + // Arrange + var graph = new LayoutGraph(); + var outer = graph.AddNode("outer", 10, 10); + var inner = outer.Children.AddNode("inner", 10, 10); + var leaf1 = inner.Children.AddNode("leaf1", 80, 40); + var leaf2 = inner.Children.AddNode("leaf2", 80, 40); + inner.Children.AddEdge("leaf1-leaf2", leaf1, leaf2); + + // Act + var autoTree = new AutoLayoutAlgorithm().Apply(graph); + var hierarchicalTree = new HierarchicalLayoutAlgorithm().Apply(graph); + + // Assert + Assert.Equal(DumpTree(hierarchicalTree), DumpTree(autoTree)); + } + + /// + /// Proves that a graph mixing one connected cluster with several unrelated singleton nodes packs + /// the cluster (via layered) and the singleton bucket (via containment) into one combined tree + /// with non-overlapping bounding boxes for the two pieces. + /// + [Fact] + public void Apply_ClusterPlusIsolatedSingletons_PacksBothGroupsWithoutOverlap() + { + // Arrange + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 80, 40); + var b = graph.AddNode("b", 80, 40); + a.Label = "A"; + b.Label = "B"; + graph.AddEdge("a-b", a, b); + graph.AddNode("solo1", 80, 40).Label = "Solo1"; + graph.AddNode("solo2", 80, 40).Label = "Solo2"; + + // Act + var tree = new AutoLayoutAlgorithm().Apply(graph); + + // Assert: every original label is present exactly once in the combined tree. + var boxes = tree.Nodes.OfType().ToList(); + Assert.Equal(["A", "B", "Solo1", "Solo2"], boxes.Select(box => box.Label).OrderBy(label => label, StringComparer.Ordinal)); + + // Every box lies fully within the reported canvas, and no two boxes overlap. + foreach (var box in boxes) + { + Assert.True(box.X >= 0); + Assert.True(box.Y >= 0); + Assert.True(box.X + box.Width <= tree.Width + 1e-6); + Assert.True(box.Y + box.Height <= tree.Height + 1e-6); + } + + for (var i = 0; i < boxes.Count; i++) + { + for (var j = i + 1; j < boxes.Count; j++) + { + var overlapsX = boxes[i].X < boxes[j].X + boxes[j].Width && boxes[j].X < boxes[i].X + boxes[i].Width; + var overlapsY = boxes[i].Y < boxes[j].Y + boxes[j].Height && boxes[j].Y < boxes[i].Y + boxes[i].Height; + Assert.False(overlapsX && overlapsY, $"Boxes '{boxes[i].Label}' and '{boxes[j].Label}' overlap."); + } + } + } + + /// + /// Proves that multiple disconnected clusters (each with two or more nodes, no children) each + /// route through layered and are packed without overlapping each other. + /// + [Fact] + public void Apply_MultipleDisconnectedClusters_PacksEachClusterWithoutOverlap() + { + // Arrange: two independent two-node clusters. + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 80, 40); + var b = graph.AddNode("b", 80, 40); + graph.AddEdge("a-b", a, b); + var c = graph.AddNode("c", 80, 40); + var d = graph.AddNode("d", 80, 40); + graph.AddEdge("c-d", c, d); + + // Act + var tree = new AutoLayoutAlgorithm().Apply(graph); + + // Assert: four boxes total, two lines (one per cluster's edge). + Assert.Equal(4, tree.Nodes.OfType().Count()); + Assert.Equal(2, tree.Nodes.OfType().Count()); + } + + /// + /// Proves that a component containing a self-loop (a single node with an edge to itself, and no + /// children) is routed through layered rather than being folded into the singleton bucket. + /// + [Fact] + public void Apply_SingleNodeWithSelfLoop_RoutesToLayeredNotContainment() + { + // Arrange + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 80, 40); + graph.AddEdge("self", a, a); + + // Act + var autoTree = new AutoLayoutAlgorithm().Apply(graph); + var layeredTree = new LayeredLayoutAlgorithm().Apply(graph); + + // Assert + Assert.Equal(DumpTree(layeredTree), DumpTree(autoTree)); + } + + /// + /// Proves that a graph-level cascaded option () actually + /// reaches a split-off component's leaf algorithm, since the effective options are captured + /// before splitting. Regression test: the prior version of this test only asserted a box count, + /// so a regression that dropped the captured options (or otherwise ignored NodeSpacing on the + /// split path) would still have passed. + /// + [Fact] + public void Apply_GraphLevelOptionOverride_AppliesToSplitComponents() + { + // Arrange: a two-sibling fan-out cluster {a -> b, a -> c} (forces the multi-group split path + // and gives the layered leaf a same-layer sibling pair whose gap is driven by NodeSpacing) plus + // an unrelated singleton, laid out twice with only the graph-level NodeSpacing changed. + static double SiblingGap(double nodeSpacing) + { + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 80, 40); + var b = graph.AddNode("b", 80, 40); + var c = graph.AddNode("c", 80, 40); + b.Label = "b"; + c.Label = "c"; + graph.AddEdge("a-b", a, b); + graph.AddEdge("a-c", a, c); + graph.AddNode("solo", 80, 40); + graph.Set(CoreOptions.NodeSpacing, nodeSpacing); + + var tree = new AutoLayoutAlgorithm().Apply(graph); + var boxes = tree.Nodes.OfType().ToList(); + Assert.Equal(4, boxes.Count); + + var bBox = boxes.Single(box => box.Label == "b"); + var cBox = boxes.Single(box => box.Label == "c"); + return Math.Abs(bBox.Y - cBox.Y); + } + + // Act + var narrowGap = SiblingGap(5.0); + var wideGap = SiblingGap(300.0); + + // Assert: the same-layer sibling gap between b and c grows with the graph-level NodeSpacing + // override, proving the split layered component actually received the captured cascaded option + // rather than falling back to a default. + Assert.True( + wideGap > narrowGap + 250.0, + $"expected the wide-spacing sibling gap ({wideGap}) to exceed the narrow-spacing gap ({narrowGap}) by more than 250, proving NodeSpacing reached the split component"); + } + + /// + /// Proves that when the graph itself explicitly declares CoreOptions.Algorithm = "auto" + /// (as a caller resolving the algorithm from the graph's own options, rather than passing it + /// directly, would do) and the multi-group split path routes one group to hierarchical, the + /// hierarchical algorithm's own recursive scope resolution does not attempt to resolve "auto" + /// from its own leaf-only registry and throw. Regression test for a bug where the captured + /// effective options still carried the root graph's own "auto" value down into a nested + /// algorithm's cascade. + /// + [Fact] + public void Apply_GraphDeclaresAutoAlgorithmWithNestedContainerGroup_DoesNotThrow() + { + // Arrange: the graph itself declares "auto" (mirroring a caller that resolves the algorithm from + // the graph's own options), one component is a two-level-deep nested container (forcing a + // hierarchical-routed group whose own nested scope does not declare an override), and an + // unrelated singleton forces the genuine multi-group split path rather than the fast path. + var graph = new LayoutGraph(); + graph.Set(CoreOptions.Algorithm, "auto"); + + var outer = graph.AddNode("outer", 10, 10); + var inner = outer.Children.AddNode("inner", 10, 10); + var leaf1 = inner.Children.AddNode("leaf1", 80, 40); + var leaf2 = inner.Children.AddNode("leaf2", 80, 40); + inner.Children.AddEdge("leaf1-leaf2", leaf1, leaf2); + + graph.AddNode("solo", 80, 40); + + // Act + var tree = new AutoLayoutAlgorithm().Apply(graph); + + // Assert: no exception was thrown, and every box (the outer container, the inner container, the + // two leaves, and the solo singleton) made it into the combined tree. + Assert.Equal(5, CountBoxesRecursively(tree.Nodes)); + } + + /// Counts every in a node list, recursing into each box's children. + /// The nodes to count within. + /// The total number of boxes found. + private static int CountBoxesRecursively(IEnumerable nodes) + { + var count = 0; + foreach (var node in nodes) + { + if (node is not LayoutBox box) + { + continue; + } + + count++; + count += CountBoxesRecursively(box.Children); + } + + return count; + } + + /// + /// Serializes a layout tree into a stable, human-readable string capturing every box rectangle + /// (recursively) and every line's waypoints, so two independently-produced trees can be compared + /// for content equality without relying on reference-equality collection semantics. + /// + /// The tree to serialize. + /// The deterministic textual snapshot. + private static string DumpTree(LayoutTree tree) + { + var builder = new System.Text.StringBuilder(); + builder.Append(System.Globalization.CultureInfo.InvariantCulture, $"W={tree.Width:R} H={tree.Height:R}\n"); + foreach (var node in tree.Nodes) + { + DumpNode(builder, node, 0); + } + + return builder.ToString(); + } + + /// Appends a single node (and its children) to the snapshot builder. + private static void DumpNode(System.Text.StringBuilder builder, LayoutNode node, int depth) + { + var indent = new string(' ', depth * 2); + var culture = System.Globalization.CultureInfo.InvariantCulture; + switch (node) + { + case LayoutBox box: + builder.Append(culture, $"{indent}BOX {box.Label} X={box.X:R} Y={box.Y:R} W={box.Width:R} H={box.Height:R}\n"); + foreach (var child in box.Children) + { + DumpNode(builder, child, depth + 1); + } + + break; + + case LayoutLine line: + builder.Append(culture, $"{indent}LINE"); + foreach (var wp in line.Waypoints) + { + builder.Append(culture, $" ({wp.X:R},{wp.Y:R})"); + } + + builder.Append('\n'); + break; + + case LayoutPort port: + builder.Append(culture, $"{indent}PORT {port.ExternalLabel}/{port.InternalLabel} X={port.CentreX:R} Y={port.CentreY:R}\n"); + break; + + default: + builder.Append(culture, $"{indent}{node.GetType().Name}\n"); + break; + } + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs index 15bd6db..cc9d196 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs @@ -755,6 +755,74 @@ public void Route_NineParallelEdgesIntoCompartmentBox_DoNotCrossTargetInterior() }); } + /// + /// Regression test for the "coiled/looping" connector-router robustness bug: nine parallel, + /// unmerged edges from a small source box into a much taller multi-row-compartment target box, + /// separated by a narrow gap, must all route as clean fans that never stray outside the two + /// boxes' own bounding envelope. Before the fix, OrthogonalEdgeRouter's soft-obstacle + /// lane candidates marched outward without bound as each successive connector became a soft + /// obstacle for the next (see OrthogonalEdgeRouter.AddSoftCandidate), eventually landing + /// a lane behind the source box's own footprint — producing a path that leaves toward the gap, + /// reverses back underneath the source box, then recovers. The existing + /// test uses the + /// same geometry but only asserts no segment cuts through either box's interior, which this + /// defect does not trigger (the loop-back travels behind, not through, the source box), so it + /// does not catch this bug. This test's bounding-envelope assertion does. + /// + [Fact] + public void Route_NineParallelEdgesIntoCompartmentBox_DoNotLoopBehindEitherBox() + { + // Arrange: same geometry as Route_NineParallelEdgesIntoCompartmentBox_DoNotCrossTargetInterior + // — a small port box beside a much taller multi-row-compartment box, joined by nine distinct + // unmerged edges. + 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: no waypoint of any routed connector strays outside the bounding envelope of the two + // connected boxes (expanded by a generous allowance for the approach stub) — this is what + // "looping back behind a box already left" violates, and what a clean fan-out never needs. + AssertWaypointsWithinBoxPairEnvelope(lines, eePort, lbo); + Assert.All(lines, line => AssertAllSegmentsOrthogonal(line.Waypoints)); + AssertNoTransversalCrossingAcrossLines(lines); + } + + /// + /// Asserts that every waypoint of every routed line lies within the bounding envelope of the two + /// connected boxes, expanded by a generous fixed allowance for the router's own perpendicular + /// approach stub (see OrthogonalEdgeRouter.StepOff). A route that needs to travel further + /// than this outside the pair's own footprint has looped back behind a box it already left, + /// rather than forming a clean fan between the two boxes. + /// + private static void AssertWaypointsWithinBoxPairEnvelope( + IReadOnlyList lines, + LayoutBox a, + LayoutBox b) + { + const double allowance = 20.0; + var envelope = new Rect( + Math.Min(a.X, b.X) - allowance, + Math.Min(a.Y, b.Y) - allowance, + Math.Max(a.X + a.Width, b.X + b.Width) - Math.Min(a.X, b.X) + (2 * allowance), + Math.Max(a.Y + a.Height, b.Y + b.Height) - Math.Min(a.Y, b.Y) + (2 * allowance)); + + Assert.All(lines, line => Assert.All(line.Waypoints, p => AssertPointInsideRect(envelope, p))); + } + + /// Asserts that a point lies within (or on the boundary of) the given rectangle. + private static void AssertPointInsideRect(Rect r, Point2D p) => + Assert.True( + p.X >= r.X && p.X <= r.X + r.Width && p.Y >= r.Y && p.Y <= r.Y + r.Height, + $"Point ({p.X},{p.Y}) lies outside the expected bounding envelope " + + $"({r.X},{r.Y})-({r.X + r.Width},{r.Y + r.Height})."); + /// /// 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 @@ -831,6 +899,93 @@ private static double CollinearOverlapLength(Point2D a1, Point2D b1, Point2D a2, return 0.0; } + /// + /// Asserts that no two of the given lines' interior segments (same endpoint-adjacent + /// exclusion as ) transversally cross one + /// another — i.e. one is horizontal, the other vertical, and they intersect strictly inside both + /// segments' interiors, not merely at a shared endpoint. This is the direct "no two distinct + /// connectors' paths cross" check: unlike + /// (which only flags two parallel segments riding the same lane for an extended span), + /// a transversal crossing is a genuine perpendicular "X" intersection between two different + /// connectors, which is visually a crossing regardless of how short the shared point is. + /// + private static void AssertNoTransversalCrossingAcrossLines(IReadOnlyList lines) + { + // Collect each line's interior segments (excluding the first and last, matching + // AddLineObstacles' own endpoint-adjacent exclusion and AssertNoCollinearOverlapAcrossLines' + // convention above). + var interiorSegments = new List<(int LineIndex, Point2D A, Point2D B)>(); + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var waypoints = lines[lineIndex].Waypoints; + for (var i = 1; i < waypoints.Count - 2; i++) + { + interiorSegments.Add((lineIndex, waypoints[i], waypoints[i + 1])); + } + } + + for (var x = 0; x < interiorSegments.Count; x++) + { + for (var y = x + 1; y < interiorSegments.Count; y++) + { + var (lineX, ax, bx) = interiorSegments[x]; + var (lineY, ay, by) = interiorSegments[y]; + if (lineX == lineY) + { + // Only cross-connector crossings are of interest here. + continue; + } + + Assert.False( + SegmentsCrossTransversally(ax, bx, ay, by), + $"Interior segments of connectors {lineX} and {lineY} cross transversally " + + $"(({ax.X},{ax.Y})-({bx.X},{bx.Y}) vs ({ay.X},{ay.Y})-({by.X},{by.Y}))."); + } + } + } + + /// + /// Returns when one of the two axis-aligned segments is horizontal, the + /// other vertical, and they intersect strictly inside both segments' interiors (not merely at a + /// shared endpoint or a collinear touch). Uses the same tight tolerance convention as + /// / elsewhere in this file. + /// + private static bool SegmentsCrossTransversally(Point2D a1, Point2D b1, Point2D a2, Point2D b2) + { + const double tolerance = 1e-6; + + var horizontal1 = Math.Abs(a1.Y - b1.Y) < tolerance; + var vertical1 = Math.Abs(a1.X - b1.X) < tolerance; + var horizontal2 = Math.Abs(a2.Y - b2.Y) < tolerance; + var vertical2 = Math.Abs(a2.X - b2.X) < tolerance; + + // Only interested in one horizontal + one vertical segment; two parallel segments (both + // horizontal or both vertical) can never cross transversally. + Point2D hA, hB, vA, vB; + if (horizontal1 && vertical2) + { + (hA, hB, vA, vB) = (a1, b1, a2, b2); + } + else if (vertical1 && horizontal2) + { + (hA, hB, vA, vB) = (a2, b2, a1, b1); + } + else + { + return false; + } + + var y = hA.Y; + var xLo = Math.Min(hA.X, hB.X); + var xHi = Math.Max(hA.X, hB.X); + var x = vA.X; + var yLo = Math.Min(vA.Y, vB.Y); + var yHi = Math.Max(vA.Y, vB.Y); + + return x > xLo + tolerance && x < xHi - tolerance && + y > yLo + tolerance && y < yHi - tolerance; + } + /// /// A null box list is rejected by the batch overload. /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs index 72b7eba..8e51c8f 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs @@ -267,6 +267,115 @@ public void Apply_EdgeRoutingOverrideOnGraphScope_IsHonored() Assert.True(line.Waypoints.Count >= 2); } + /// + /// Proves that many small wide boxes (a shape whose area-based content width would otherwise + /// under-estimate the right packing budget) wrap into more than one row, instead of a single + /// narrow column, thanks to the column-count-based width candidate. + /// + [Fact] + public void Apply_ManySmallWideBoxes_PacksIntoMultipleColumns() + { + // Arrange: twelve small, wide boxes (160x40) — area alone would suggest a narrow content width, + // but a column-count-based candidate should widen the budget enough for multiple columns. + var graph = new LayoutGraph(); + for (var i = 0; i < 12; i++) + { + graph.AddNode($"n{i}", 160, 40).Label = $"N{i}"; + } + + // Act + var tree = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions()); + + // Assert: the boxes span more than one distinct row (Y position) — i.e., multi-column packing — + // rather than being forced into a single long column. + var boxes = tree.Nodes.OfType().ToList(); + Assert.Equal(12, boxes.Count); + var distinctRows = boxes.Select(box => box.Y).Distinct().Count(); + Assert.True(distinctRows > 1, $"Expected multiple rows, but all {boxes.Count} boxes landed on {distinctRows} row(s)."); + + var distinctColumns = boxes.Select(box => box.X).Distinct().Count(); + Assert.True(distinctColumns > 1, $"Expected multiple columns, but all {boxes.Count} boxes landed on {distinctColumns} column(s)."); + } + + /// + /// Proves the end-to-end containment path widens the horizontal gap between two same-row peer + /// boxes joined by a fan of parallel edges: the gap grows past the default NodeSpacing to + /// the connector-corridor width, giving each connector its own routing lane. + /// + [Fact] + public void Apply_SameRowPeersWithParallelEdges_WidensGapPastNodeSpacing() + { + // Arrange: two tall, narrow peer boxes sized so the packer places them side by side, joined by + // eight parallel edges. Tall/narrow keeps 2*width + NodeSpacing under the area-based content + // width so both land on one row. + var graph = new LayoutGraph(); + var left = graph.AddNode("left", 100, 218); + var right = graph.AddNode("right", 100, 218); + left.Label = "Left"; + right.Label = "Right"; + for (var i = 0; i < 8; i++) + { + graph.AddEdge($"e{i}", left, right); + } + + // Act + var tree = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions()); + + // Assert: the two boxes share a row and the gap between them equals the eight-connector corridor + // width (2*10 + 7*16 = 132), comfortably wider than the 24px NodeSpacing default. + var boxes = tree.Nodes.OfType().OrderBy(box => box.X).ToList(); + Assert.Equal(2, boxes.Count); + Assert.Equal(boxes[0].Y, boxes[1].Y); + var gap = boxes[1].X - (boxes[0].X + boxes[0].Width); + Assert.Equal(132.0, gap); + Assert.True(gap > 24.0, "Expected the fan to widen the gap past the 24px NodeSpacing default."); + } + + /// + /// Proves the horizontal-only widening leaves a vertical stack byte-identical: a small Source box + /// stacked above a tall Target box (joined by nine parallel edges) lands at exactly the same box + /// positions as the same boxes with no edges at all, because the pair spans two rows and the + /// widening never touches the vertical gap. + /// + [Fact] + public void Apply_VerticalStackWithParallelEdges_LeavesBoxPositionsUnchanged() + { + // Arrange: a small Source over a tall Target — sized so 2*width + NodeSpacing exceeds the + // area-based content width, forcing the Target to wrap below the Source (a vertical stack). + static LayoutGraph BuildGraph(int edgeCount) + { + var graph = new LayoutGraph(); + var source = graph.AddNode("source", 130, 50); + var target = graph.AddNode("target", 130, 242); + source.Label = "Source"; + target.Label = "Target"; + for (var i = 0; i < edgeCount; i++) + { + graph.AddEdge($"e{i}", source, target); + } + + return graph; + } + + // Act: lay out the stack with nine parallel edges and, as the baseline, with no edges at all. + var withEdges = new ContainmentLayoutAlgorithm().ApplyCore(BuildGraph(9), new LayoutOptions()); + var withoutEdges = new ContainmentLayoutAlgorithm().ApplyCore(BuildGraph(0), new LayoutOptions()); + + // Assert: the boxes stack (distinct rows) and every box position is byte-identical between the + // two runs — the horizontal widening left the vertical gap and the stack untouched. + var edgedBoxes = withEdges.Nodes.OfType().ToList(); + var plainBoxes = withoutEdges.Nodes.OfType().ToList(); + Assert.Equal(2, edgedBoxes.Count); + Assert.True(edgedBoxes[1].Y > edgedBoxes[0].Y, "Expected the Target to wrap below the Source."); + for (var i = 0; i < plainBoxes.Count; i++) + { + Assert.Equal(plainBoxes[i].X, edgedBoxes[i].X); + Assert.Equal(plainBoxes[i].Y, edgedBoxes[i].Y); + Assert.Equal(plainBoxes[i].Width, edgedBoxes[i].Width); + Assert.Equal(plainBoxes[i].Height, edgedBoxes[i].Height); + } + } + /// /// Determines whether two boxes overlap with a positive-area intersection. /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs index 067a502..247cb25 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs @@ -230,6 +230,47 @@ public void Pack_NullChildElement_Throws() () => ContainmentLayout.Pack(children, new ContainmentOptions(MaxContentWidth: 100))); } + /// + /// Supplying an entry for an adjacent same-row pair + /// widens exactly that horizontal gap to the connector-corridor width, pushing the second box + /// right while leaving the first at the origin. + /// + [Fact] + public void Pack_EdgeCounts_WidensIndicatedRowGap() + { + // Arrange: two boxes that share a row, with eight connectors between the (0, 1) pair + var children = new[] { Box(60, 40, "first"), Box(60, 40, "second") }; + var edgeCounts = new Dictionary<(int First, int Second), int> { [(0, 1)] = 8 }; + + // Act + var result = ContainmentLayout.Pack( + children, new ContainmentOptions(MaxContentWidth: 400, EdgeCounts: edgeCounts)); + + // Assert: the pair stays on one row and the gap between them equals the corridor width + // (2*10 + 7*16 = 132) rather than the default 8px horizontal gap. + Assert.Equal(result.Children[0].Y, result.Children[1].Y); + var gap = result.Children[1].X - (result.Children[0].X + result.Children[0].Width); + Assert.Equal(132.0, gap); + } + + /// + /// Omitting leaves placement at the default + /// horizontal gap, byte-identical to the pre-existing no-counts behaviour. + /// + [Fact] + public void Pack_WithoutEdgeCounts_UsesDefaultGap() + { + // Arrange: the same two boxes, but no edge counts supplied + var children = new[] { Box(60, 40, "first"), Box(60, 40, "second") }; + + // Act + var result = ContainmentLayout.Pack(children, new ContainmentOptions(MaxContentWidth: 400)); + + // Assert: the boxes are separated only by the default 8px horizontal gap + var gap = result.Children[1].X - (result.Children[0].X + result.Children[0].Width); + Assert.Equal(8.0, gap); + } + /// /// Determines whether two boxes overlap with a positive-area intersection. /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/ContainmentPackerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/ContainmentPackerTests.cs index 0a001fb..ed8ab1b 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/ContainmentPackerTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/ContainmentPackerTests.cs @@ -46,6 +46,18 @@ public void Pack_SingleItem_PositionsAtPaddingOrigin() Assert.Equal(40.0, result.Height); } + /// + /// A null items argument throws ArgumentNullException rather than failing partway through the + /// packing algorithm with a less specific exception. + /// + [Fact] + public void Pack_NullItems_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws( + () => ContainmentPacker.Pack(null!, maxContentWidth: 200, horizontalGap: 5, verticalGap: 5, padding: 10)); + } + /// /// Items that fit within the max content width are placed on a single row sharing a Y. /// @@ -159,6 +171,85 @@ public void Pack_ItemWiderThanContentWidth_PlacedAloneAndRegionWidens() Assert.True(result.Width >= 320.0); } + /// + /// A per-pair edge count widens only the one horizontal gap between the two adjacent same-row + /// items it names, spreading them apart by the connector-corridor width for that fan. + /// + [Fact] + public void Pack_SameRowEdgeCount_WidensHorizontalGap() + { + // Arrange: two 40-wide items that share a row, with eight connectors between them + var items = new[] { new PackItem(40, 20), new PackItem(40, 20) }; + var edgeCounts = new Dictionary<(int First, int Second), int> { [(0, 1)] = 8 }; + + // Act: pack with and without the edge-count lookup + var widened = ContainmentPacker.Pack( + items, maxContentWidth: 400, horizontalGap: 5, verticalGap: 5, padding: 10, edgeCounts); + var plain = ContainmentPacker.Pack( + items, maxContentWidth: 400, horizontalGap: 5, verticalGap: 5, padding: 10); + + // Assert: the second item is pushed right by the corridor width (2*10 + 7*16 = 132) beyond the + // base gap, so its X sits at padding + width + 132 rather than padding + width + baseGap. + Assert.Equal(10.0, widened.Rects[0].X); + Assert.Equal(10.0 + 40.0 + 132.0, widened.Rects[1].X); + + // And the widened placement is strictly further right than the un-widened one. + Assert.True(widened.Rects[1].X > plain.Rects[1].X); + } + + /// + /// Supplying a edge-count lookup produces byte-identical placement to a + /// caller that supplied no lookup at all, guarding the backward-compatible default path. + /// + [Fact] + public void Pack_NullEdgeCounts_ByteIdenticalToNoCounts() + { + // Arrange: a mix that forces two rows so both axes are exercised + var items = new[] + { + new PackItem(60, 30), new PackItem(120, 20), new PackItem(40, 50), new PackItem(90, 25), + }; + + // Act: one call passes null explicitly, the other omits the argument + var withNull = ContainmentPacker.Pack( + items, maxContentWidth: 200, horizontalGap: 8, verticalGap: 8, padding: 12, edgeCounts: null); + var withoutArg = ContainmentPacker.Pack( + items, maxContentWidth: 200, horizontalGap: 8, verticalGap: 8, padding: 12); + + // Assert: identical region and identical per-rectangle placement + Assert.Equal(withoutArg.Width, withNull.Width); + Assert.Equal(withoutArg.Height, withNull.Height); + for (var i = 0; i < withoutArg.Rects.Count; i++) + { + Assert.Equal(withoutArg.Rects[i].X, withNull.Rects[i].X); + Assert.Equal(withoutArg.Rects[i].Y, withNull.Rects[i].Y); + } + } + + /// + /// An edge count for a pair that the un-widened wrap decision splits across two rows never + /// widens anything: the pair is no longer same-row, so placement matches the no-counts output. + /// + [Fact] + public void Pack_DifferentRowPair_Unaffected() + { + // Arrange: two 80-wide items with only 100 content width, so the second wraps to a new row + var items = new[] { new PackItem(80, 20), new PackItem(80, 20) }; + var edgeCounts = new Dictionary<(int First, int Second), int> { [(0, 1)] = 8 }; + + // Act: the (0, 1) pair spans two rows, so its count must not apply + var widened = ContainmentPacker.Pack( + items, maxContentWidth: 100, horizontalGap: 5, verticalGap: 5, padding: 10, edgeCounts); + var plain = ContainmentPacker.Pack( + items, maxContentWidth: 100, horizontalGap: 5, verticalGap: 5, padding: 10); + + // Assert: the wrapped item starts at the left origin in both cases, unmoved by the edge count + Assert.True(widened.Rects[1].Y > widened.Rects[0].Y); + Assert.Equal(plain.Rects[1].X, widened.Rects[1].X); + Assert.Equal(plain.Rects[1].Y, widened.Rects[1].Y); + Assert.Equal(plain.Width, widened.Width); + } + /// /// Determines whether two rectangles overlap with a positive-area intersection. /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/EdgeCountGapWidenerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/EdgeCountGapWidenerTests.cs new file mode 100644 index 0000000..917321e --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/EdgeCountGapWidenerTests.cs @@ -0,0 +1,108 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine; + +/// +/// Tests for , the shared connector-corridor gap formula used by +/// both the containment packer and the hierarchical algorithm's sibling-widening pass. +/// +public sealed class EdgeCountGapWidenerTests +{ + /// + /// A fan of several connectors widens the gap to the corridor width — a clearance on each side + /// plus one inter-connector spacing per gap between adjacent connectors. + /// + [Fact] + public void Widen_ManyConnectors_ReturnsCorridorWidth() + { + // Arrange: a base gap far below the corridor width for eight connectors + // Corridor = 2*ConnectorClearance(10) + (8 - 1)*EdgeSpacing(16) = 20 + 112 = 132. + const double baseGap = 5.0; + + // Act + var result = EdgeCountGapWidener.Widen(baseGap, edgeCount: 8); + + // Assert + Assert.Equal(132.0, result); + } + + /// + /// Two connectors widen the gap to a single clearance-bounded slot (one inter-connector spacing). + /// + [Fact] + public void Widen_TwoConnectors_ReturnsSingleSlotCorridor() + { + // Corridor = 2*10 + (2 - 1)*16 = 20 + 16 = 36. + var result = EdgeCountGapWidener.Widen(baseGap: 5.0, edgeCount: 2); + + Assert.Equal(36.0, result); + } + + /// + /// A single connector needs no extra lane, so the base gap is returned unchanged. + /// + [Fact] + public void Widen_SingleConnector_ReturnsBaseGap() + { + // Corridor for one connector = 2*10 + 0 = 20, below the base gap, so the base gap wins. + var result = EdgeCountGapWidener.Widen(baseGap: 24.0, edgeCount: 1); + + Assert.Equal(24.0, result); + } + + /// + /// A single connector needs no extra lane even when the base gap is narrower than the fixed + /// 2*ConnectorClearance floor that the corridor formula would otherwise apply — the degenerate + /// short-circuit must return the base gap unchanged rather than widen it. + /// + [Fact] + public void Widen_SingleConnector_BaseGapBelowClearanceFloor_ReturnsBaseGapUnwidened() + { + // Corridor for one connector = 2*10 + 0 = 20, which exceeds this base gap of 8, so a naive + // Math.Max would incorrectly widen it. The degenerate short-circuit must prevent that. + var result = EdgeCountGapWidener.Widen(baseGap: 8.0, edgeCount: 1); + + Assert.Equal(8.0, result); + } + + /// + /// A zero (or negative) connector count never narrows the gap below the supplied base gap. + /// + [Fact] + public void Widen_ZeroConnectors_ReturnsBaseGap() + { + // Corridor = 2*10 + (0 - 1)*16 = 20 - 16 = 4, below the base gap, so the base gap wins. + var result = EdgeCountGapWidener.Widen(baseGap: 24.0, edgeCount: 0); + + Assert.Equal(24.0, result); + } + + /// + /// A zero connector count never widens the gap either, even when the base gap is very small — + /// the degenerate short-circuit applies uniformly to counts of zero and one. + /// + [Fact] + public void Widen_ZeroConnectors_BaseGapBelowClearanceFloor_ReturnsBaseGapUnwidened() + { + var result = EdgeCountGapWidener.Widen(baseGap: 3.0, edgeCount: 0); + + Assert.Equal(3.0, result); + } + + /// + /// When the base gap already exceeds the corridor width, the base gap is preserved (the widener + /// never shrinks a gap). + /// + [Fact] + public void Widen_BaseGapExceedsCorridor_ReturnsBaseGap() + { + // Corridor for two connectors = 36; a base gap of 200 already exceeds it. + var result = EdgeCountGapWidener.Widen(baseGap: 200.0, edgeCount: 2); + + Assert.Equal(200.0, result); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/ComponentPackerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/ComponentPackerTests.cs index 0b0628a..8b25382 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/ComponentPackerTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/ComponentPackerTests.cs @@ -135,6 +135,44 @@ public void ComponentPacker_Apply_NullGraph_Throws() Assert.Throws(() => ComponentPacker.WithDefaultStages().Apply(null!)); } + /// + /// Composing as an inner stage of a + /// for a Down/Up graph does not double-swap the input node axes. Regression test: before + /// was made idempotent via + /// , the pipeline's own normalization call followed by + /// 's internal call swapped the same graph's node axes twice, + /// undoing the swap and handing the direction-agnostic stages the wrong along/cross extents. + /// + [Fact] + public void ComponentPacker_Apply_ComposedInDownPipeline_DoesNotDoubleSwapAxes() + { + // Arrange: a tall/wide node whose Down-direction along-extent (height) differs sharply from its + // Right-direction along-extent (width), so a double-swap would be detectable via node placement. + var wideNode = new LayerNode(200.0, 20.0); + var nodes = new List { wideNode, wideNode }; + var edges = new List { new(0, 1) }; + + // Reference: run ComponentPacker directly (single normalization) for a Down graph. + var direct = new LayeredGraph(CloneNodes(nodes), CloneEdges(edges), LayoutDirection.Down); + ComponentPacker.WithDefaultStages().Apply(direct); + + // Act: compose ComponentPacker as the sole stage of a pipeline, which normalizes the graph's axes + // itself before running its stage list — exercising the double-normalization seam. + var composed = new LayeredGraph(CloneNodes(nodes), CloneEdges(edges), LayoutDirection.Down); + var pipeline = LayeredLayoutPipeline.Builder() + .Direction(LayoutDirection.Down) + .AddStage(ComponentPacker.WithDefaultStages()) + .Build(); + pipeline.Run(composed); + + // Assert: both paths normalize the axes exactly once, so the packed node layout matches. + for (var i = 0; i < nodes.Count; i++) + { + Assert.Equal(direct.AugX[i], composed.AugX[i], 6); + Assert.Equal(direct.AugY[i], composed.AugY[i], 6); + } + } + /// /// Repeated layouts of the same disconnected graph produce identical coordinates, confirming a /// deterministic component order. diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredPipelineEquivalenceTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredPipelineEquivalenceTests.cs index 59b4c67..274d0dc 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredPipelineEquivalenceTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredPipelineEquivalenceTests.cs @@ -14,8 +14,9 @@ namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; /// and the refactored façade and asserts that every /// field of the resulting LayerResult is bit-for-bit identical: rectangles, total /// dimensions, node layers, and every connector waypoint. No numeric tolerance is allowed, -/// except for the one documented, intentional divergence identified by -/// (the isolated-node layer-gap fix). +/// except for the two documented, intentional divergences identified by +/// (the isolated-node layer-gap fix) and +/// (component-packing for disconnected graphs). /// public sealed class LayeredPipelineEquivalenceTests { @@ -23,7 +24,8 @@ public sealed class LayeredPipelineEquivalenceTests /// The pipeline reproduces the legacy engine exactly across two thousand pseudo-randomly /// generated graphs spanning empty, disconnected, cyclic, parallel-edge, self-loop, and /// long-edge topologies with varied node sizes, excluding the graphs the isolated-node - /// layer-gap fix intentionally changes (see ). + /// layer-gap fix (see ) and component packing (see + /// ) intentionally change. /// [Fact] public void Pipeline_MatchesLegacyOracle_OnRandomGraphs() @@ -32,7 +34,7 @@ public void Pipeline_MatchesLegacyOracle_OnRandomGraphs() { var (nodes, edges) = BuildRandomGraph(seed); - // A genuinely isolated node (zero incident edges) is the one documented, intentional + // A genuinely isolated node (zero incident edges) is one documented, intentional // behavior divergence between the frozen legacy oracle and the refactored pipeline: the // legacy oracle still reproduces the isolated-node layer-gap bug (CrossingMinimizer's // barycenter sort falling back to the node's arbitrary index, and BrandesKopfPlacer's @@ -47,6 +49,19 @@ public void Pipeline_MatchesLegacyOracle_OnRandomGraphs() continue; } + // A graph with two or more genuinely disconnected connected components is the second + // documented, intentional divergence: the refactored pipeline now routes every graph + // through ComponentPacker (see InterconnectionLayoutEngine.Place), which splits, lays out, + // and shelf-packs each component separately instead of stacking every component into one + // shared-layer column the way the frozen legacy oracle still does. See + // Place_DisconnectedComponents_PacksEachComponentSeparately for a direct test of the new + // behavior, and docs/reqstream/rendering-layout/engine/layered-pipeline.yaml for the + // requirement this corresponds to. + if (HasMultipleComponents(nodes, edges)) + { + continue; + } + AssertEquivalent($"random seed {seed}", nodes, edges); } } @@ -126,7 +141,10 @@ public void Pipeline_MatchesLegacyOracle_OnWorkstationLikeGraph() /// /// Canonical named topologies (chain, diamond, long edge, self loop, parallel edges, - /// disconnected components, and a tight cycle) each produce identical results. + /// and a tight cycle) each produce identical results. The "disconnected" topology is + /// intentionally excluded here — see + /// , which asserts + /// the new (intentionally divergent) component-packing behavior directly. /// /// A human-readable name for the topology, used in failure messages. [Theory] @@ -135,7 +153,6 @@ public void Pipeline_MatchesLegacyOracle_OnWorkstationLikeGraph() [InlineData("longedge")] [InlineData("selfloop")] [InlineData("parallel")] - [InlineData("disconnected")] [InlineData("cycle")] public void Pipeline_MatchesLegacyOracle_OnNamedTopologies(string name) { @@ -146,7 +163,6 @@ public void Pipeline_MatchesLegacyOracle_OnNamedTopologies(string name) "longedge" => (Sizes(4), Edges((0, 1), (1, 2), (2, 3), (0, 3))), "selfloop" => (Sizes(3), Edges((0, 0), (0, 1), (1, 2))), "parallel" => (Sizes(2), Edges((0, 1), (0, 1), (0, 1))), - "disconnected" => (Sizes(6), Edges((0, 1), (2, 3), (4, 5))), "cycle" => (Sizes(3), Edges((0, 1), (1, 2), (2, 0))), _ => (Sizes(0), Edges()), }; @@ -154,6 +170,66 @@ public void Pipeline_MatchesLegacyOracle_OnNamedTopologies(string name) AssertEquivalent(name, nodes, edges); } + /// + /// A graph made of three disconnected 2-node pairs (the former "disconnected" named + /// topology) now routes through 's + /// component-packing behavior (see ): each pair is laid out as + /// its own component and the three components are shelf-packed into non-overlapping + /// bounding boxes, rather than being stacked into one shared-layer column the way the legacy + /// oracle still does. This directly asserts the new, intentionally divergent behavior in + /// place of the removed bit-for-bit comparison against the legacy oracle. + /// + [Fact] + public void Place_DisconnectedComponents_PacksEachComponentSeparately() + { + // Arrange: three independent 2-node pairs, no cross-pair edges. + var nodes = Sizes(6); + var edges = Edges((0, 1), (2, 3), (4, 5)); + + // Act. + var result = InterconnectionLayoutEngine.Place(nodes, edges); + + // Assert: every pair's two boxes are placed (any incident edge was routed), and no two of the + // three pair bounding boxes overlap. + Assert.Equal(6, result.Rects.Count); + + var pairs = new (int A, int B)[] { (0, 1), (2, 3), (4, 5) }; + var boxes = pairs + .Select(pair => BoundingBoxOf(result.Rects[pair.A], result.Rects[pair.B])) + .ToList(); + + for (var a = 0; a < boxes.Count; a++) + { + for (var b = a + 1; b < boxes.Count; b++) + { + Assert.False( + BoxesOverlap(boxes[a], boxes[b]), + $"component bounding boxes {a} and {b} should not overlap"); + } + } + } + + /// Computes the bounding box that encloses two rectangles. + /// First rectangle. + /// Second rectangle. + /// The smallest rectangle containing both inputs. + private static Rect BoundingBoxOf(Rect first, Rect second) + { + var minX = Math.Min(first.X, second.X); + var minY = Math.Min(first.Y, second.Y); + var maxX = Math.Max(first.X + first.Width, second.X + second.Width); + var maxY = Math.Max(first.Y + first.Height, second.Y + second.Height); + return new Rect(minX, minY, maxX - minX, maxY - minY); + } + + /// Determines whether two rectangles overlap. + /// First rectangle. + /// Second rectangle. + /// if the two rectangles intersect. + private static bool BoxesOverlap(Rect first, Rect second) => + first.X < second.X + second.Width && second.X < first.X + first.Width && + first.Y < second.Y + second.Height && second.Y < first.Y + first.Height; + /// Builds a list of uniformly sized nodes. private static List Sizes(int count) { @@ -252,6 +328,63 @@ private static bool HasIsolatedNode(List nodes, List edges return false; } + /// + /// Returns whether the graph contains two or more genuinely disconnected connected + /// components — computed independently via a small local union-find over the raw + /// list (self-loops are ignored, mirroring + /// 's own component-detection rule). A single node with no + /// edges at all also counts as exactly one component (itself), so it is not, by itself, + /// "multiple components" — only 2+ distinct components trigger the divergence. This is the + /// second documented, intentional behavior divergence between the frozen legacy oracle and + /// the refactored pipeline (component packing — see the remarks on + /// ). + /// + /// The graph's nodes. + /// The graph's edges. + /// if the graph has 2 or more connected components. + private static bool HasMultipleComponents(List nodes, List edges) + { + var n = nodes.Count; + if (n <= 1) + { + return false; + } + + var parent = new int[n]; + for (var i = 0; i < n; i++) + { + parent[i] = i; + } + + int Find(int node) + { + while (parent[node] != node) + { + node = parent[node]; + } + + return node; + } + + foreach (var edge in edges.Where(edge => edge.Source != edge.Target)) + { + var ra = Find(edge.Source); + var rb = Find(edge.Target); + if (ra != rb) + { + parent[ra] = rb; + } + } + + var roots = new HashSet(); + for (var i = 0; i < n; i++) + { + roots.Add(Find(i)); + } + + return roots.Count > 1; + } + /// /// Runs both engines on the same input and asserts bit-for-bit equality of every field /// of the resulting LayerResult. diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/LayoutTreePackerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/LayoutTreePackerTests.cs new file mode 100644 index 0000000..77a86aa --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/LayoutTreePackerTests.cs @@ -0,0 +1,278 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine; + +/// +/// Tests for , the shelf-packer that merges several independently +/// placed s into one combined tree. +/// +public sealed class LayoutTreePackerTests +{ + /// + /// Proves that Pack rejects a trees argument. + /// + [Fact] + public void Pack_NullTrees_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => LayoutTreePacker.Pack(null!, 10.0, 4.0 / 3.0)); + } + + /// + /// Proves that packing an empty list of trees yields a degenerate, empty tree rather than + /// throwing. + /// + [Fact] + public void Pack_EmptyList_ReturnsEmptyTree() + { + // Act + var packed = LayoutTreePacker.Pack([], 10.0, 4.0 / 3.0); + + // Assert + Assert.Empty(packed.Nodes); + Assert.Equal(0.0, packed.Width); + Assert.Equal(0.0, packed.Height); + } + + /// + /// Proves that packing a single tree preserves its content (same width/height, and equivalent + /// nodes translated by a no-op (0, 0) offset) rather than returning a different shape — the + /// coordinates are unchanged, but the tree instance itself is rebuilt so its node types are + /// still validated against the packer's closed set (see + /// ). + /// + [Fact] + public void Pack_SingleTree_ReturnsItUnchanged() + { + // Arrange + var box = new LayoutBox(5, 5, 80, 40, "A", 0, BoxShape.Rectangle, [], []); + var tree = new LayoutTree(90, 50, [box]); + + // Act + var packed = LayoutTreePacker.Pack([tree], 10.0, 4.0 / 3.0); + + // Assert + Assert.Equal(tree.Width, packed.Width); + Assert.Equal(tree.Height, packed.Height); + var packedBox = Assert.Single(packed.Nodes.OfType()); + Assert.Equal(box.X, packedBox.X); + Assert.Equal(box.Y, packedBox.Y); + Assert.Equal(box.Label, packedBox.Label); + } + + /// + /// Proves that packing two trees places the second one shifted to the right of the first (no + /// overlap), and that the combined canvas encloses both. + /// + [Fact] + public void Pack_TwoTrees_PlacesSecondBesideFirstWithoutOverlap() + { + // Arrange + var boxA = new LayoutBox(0, 0, 100, 50, "A", 0, BoxShape.Rectangle, [], []); + var treeA = new LayoutTree(100, 50, [boxA]); + var boxB = new LayoutBox(0, 0, 60, 30, "B", 0, BoxShape.Rectangle, [], []); + var treeB = new LayoutTree(60, 30, [boxB]); + + // Act + var packed = LayoutTreePacker.Pack([treeA, treeB], 10.0, 100.0); + + // Assert: both boxes present, translated, and non-overlapping. + var boxes = packed.Nodes.OfType().ToList(); + Assert.Equal(2, boxes.Count); + var packedA = boxes.Single(b => b.Label == "A"); + var packedB = boxes.Single(b => b.Label == "B"); + + // Same-shelf placement (wide aspect target): B sits to the right of A with the requested gap. + Assert.Equal(0.0, packedA.X); + Assert.Equal(0.0, packedA.Y); + Assert.Equal(packedA.X + packedA.Width + 10.0, packedB.X); + Assert.Equal(0.0, packedB.Y); + + // The combined canvas encloses both boxes. + Assert.True(packed.Width >= packedB.X + packedB.Width); + Assert.True(packed.Height >= Math.Max(packedA.Height, packedB.Height)); + } + + /// + /// Proves that a wide sub-tree that would overflow the target row width still gets its own + /// shelf beneath the previous row, rather than overlapping it. + /// + [Fact] + public void Pack_ManyTrees_WrapsOntoNewShelfWithoutOverlap() + { + // Arrange: five identical square trees and a tight aspect ratio, forcing shelf wrapping. + var trees = Enumerable.Range(0, 5) + .Select(i => + { + var box = new LayoutBox(0, 0, 50, 50, $"n{i}", 0, BoxShape.Rectangle, [], []); + return new LayoutTree(50, 50, [box]); + }) + .ToList(); + + // Act + var packed = LayoutTreePacker.Pack(trees, 5.0, 1.0); + + // Assert: every box lies within the canvas and no two boxes overlap. + var boxes = packed.Nodes.OfType().ToList(); + Assert.Equal(5, boxes.Count); + foreach (var box in boxes) + { + Assert.True(box.X >= 0); + Assert.True(box.Y >= 0); + Assert.True(box.X + box.Width <= packed.Width + 1e-9); + Assert.True(box.Y + box.Height <= packed.Height + 1e-9); + } + + for (var i = 0; i < boxes.Count; i++) + { + for (var j = i + 1; j < boxes.Count; j++) + { + var overlapsX = boxes[i].X < boxes[j].X + boxes[j].Width && boxes[j].X < boxes[i].X + boxes[i].Width; + var overlapsY = boxes[i].Y < boxes[j].Y + boxes[j].Height && boxes[j].Y < boxes[i].Y + boxes[i].Height; + Assert.False(overlapsX && overlapsY); + } + } + + // At least two distinct rows were needed (shelf wrapping actually occurred). + Assert.True(boxes.Select(b => b.Y).Distinct().Count() > 1); + } + + /// + /// Proves that a box's nested are translated recursively along + /// with their parent, keeping their relative position intact. + /// + [Fact] + public void Pack_BoxWithNestedChildren_TranslatesChildrenRecursively() + { + // Arrange + var child = new LayoutBox(12, 12, 40, 20, "child", 1, BoxShape.Rectangle, [], []); + var boxA = new LayoutBox(0, 0, 100, 50, "A", 0, BoxShape.Rectangle, [], []); + var treeA = new LayoutTree(100, 50, [boxA]); + var boxB = new LayoutBox(0, 0, 80, 60, "B", 0, BoxShape.Rectangle, [], [child]); + var treeB = new LayoutTree(80, 60, [boxB]); + + // Act + var packed = LayoutTreePacker.Pack([treeA, treeB], 10.0, 100.0); + + // Assert: the packed B box's child is offset by the same amount as B itself, preserving the + // original 12/12 relative offset from B's own origin. + var packedB = packed.Nodes.OfType().Single(b => b.Label == "B"); + var packedChild = Assert.Single(packedB.Children.OfType()); + Assert.Equal(packedB.X + 12, packedChild.X); + Assert.Equal(packedB.Y + 12, packedChild.Y); + } + + /// + /// Proves that a 's waypoints are all translated by the same offset as + /// the sub-tree it belongs to. + /// + [Fact] + public void Pack_TreeWithLine_TranslatesEveryWaypoint() + { + // Arrange + var boxA = new LayoutBox(0, 0, 100, 50, "A", 0, BoxShape.Rectangle, [], []); + var treeA = new LayoutTree(100, 50, [boxA]); + var line = new LayoutLine( + [new Point2D(0, 0), new Point2D(10, 0), new Point2D(10, 20)], + EndMarkerStyle.None, + EndMarkerStyle.FilledArrow, + LineStyle.Solid, + null); + var boxB = new LayoutBox(0, 0, 60, 30, "B", 0, BoxShape.Rectangle, [], []); + var treeB = new LayoutTree(60, 30, [boxB, line]); + + // Act + var packed = LayoutTreePacker.Pack([treeA, treeB], 10.0, 100.0); + + // Assert: every waypoint of the line is shifted by the same offset the second tree received. + var packedB = packed.Nodes.OfType().Single(b => b.Label == "B"); + var offsetX = packedB.X; + var offsetY = packedB.Y; + var packedLine = Assert.Single(packed.Nodes.OfType()); + Assert.Equal( + [new Point2D(offsetX, offsetY), new Point2D(offsetX + 10, offsetY), new Point2D(offsetX + 10, offsetY + 20)], + packedLine.Waypoints); + } + + /// + /// Proves that a 's centre is translated by the same offset as its + /// owning sub-tree. + /// + [Fact] + public void Pack_TreeWithPort_TranslatesCentre() + { + // Arrange + var boxA = new LayoutBox(0, 0, 100, 50, "A", 0, BoxShape.Rectangle, [], []); + var treeA = new LayoutTree(100, 50, [boxA]); + var port = new LayoutPort(60, 15, PortSide.Right, "p"); + var boxB = new LayoutBox(0, 0, 60, 30, "B", 0, BoxShape.Rectangle, [], []); + var treeB = new LayoutTree(60, 30, [boxB, port]); + + // Act + var packed = LayoutTreePacker.Pack([treeA, treeB], 10.0, 100.0); + + // Assert + var packedB = packed.Nodes.OfType().Single(b => b.Label == "B"); + var packedPort = Assert.Single(packed.Nodes.OfType()); + Assert.Equal(packedB.X + 60, packedPort.CentreX); + Assert.Equal(packedB.Y + 15, packedPort.CentreY); + } + + /// + /// Proves that warnings from every packed sub-tree are preserved in the combined tree. + /// + [Fact] + public void Pack_TreesWithWarnings_PreservesAllWarnings() + { + // Arrange + var boxA = new LayoutBox(0, 0, 100, 50, "A", 0, BoxShape.Rectangle, [], []); + var treeA = new LayoutTree(100, 50, [boxA]) { Warnings = ["warning-a"] }; + var boxB = new LayoutBox(0, 0, 60, 30, "B", 0, BoxShape.Rectangle, [], []); + var treeB = new LayoutTree(60, 30, [boxB]) { Warnings = ["warning-b1", "warning-b2"] }; + + // Act + var packed = LayoutTreePacker.Pack([treeA, treeB], 10.0, 100.0); + + // Assert + Assert.Equal(["warning-a", "warning-b1", "warning-b2"], packed.Warnings); + } + + /// + /// Proves that translating an unrecognized subtype throws + /// rather than silently leaving it at the wrong position. + /// + [Fact] + public void Pack_UnrecognizedNodeType_ThrowsNotSupportedException() + { + // Arrange + var boxA = new LayoutBox(0, 0, 100, 50, "A", 0, BoxShape.Rectangle, [], []); + var treeA = new LayoutTree(100, 50, [boxA]); + var treeB = new LayoutTree(60, 30, [new UnrecognizedLayoutNode()]); + + // Act / Assert + Assert.Throws(() => LayoutTreePacker.Pack([treeA, treeB], 10.0, 100.0)); + } + + /// + /// Proves that a singleton tree containing an unrecognized + /// subtype also throws : the single-tree fast path must not + /// bypass the same closed-set validation the multi-tree path enforces. + /// + [Fact] + public void Pack_SingleTreeWithUnrecognizedNodeType_ThrowsNotSupportedException() + { + // Arrange + var tree = new LayoutTree(60, 30, [new UnrecognizedLayoutNode()]); + + // Act / Assert + Assert.Throws(() => LayoutTreePacker.Pack([tree], 10.0, 100.0)); + } + + /// A minimal stub outside the packer's known, closed set. + private sealed record UnrecognizedLayoutNode : LayoutNode; +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs index e31cc8e..151fdd7 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs @@ -323,6 +323,119 @@ public void RouteWithStatus_ShortSoftObstacleOverlap_KeepsStraightRoute() Assert.All(result.Waypoints, p => Assert.Equal(0.0, p.Y, 6)); } + /// + /// Direct, low-level regression test for the connector-router "coiled/looping" robustness bug + /// (see OrthogonalEdgeRouter.AddSoftCandidate and + /// OrthogonalEdgeRouter.EnvelopeDepartureCost), isolated from + /// ConnectorRouter's anchor-distribution logic by calling + /// directly. Several soft obstacles are pre-seeded at ever-increasing offsets outside the true + /// source/target gap, simulating the "stair-stepping" candidate lane a stack of already-routed + /// parallel connectors produced before the fix — each successive connector's own soft-obstacle + /// avoidance offset its candidate lane a little further out, with no ceiling, until a lane landed + /// behind (outside the X-span of) the source box's own footprint. Before the fix, the unbounded + /// soft-obstacle-derived grid candidates let A* pick one of these behind-the-box lanes; after the + /// fix, the candidates are clamped to the source/target envelope, and any residual temptation to + /// leave it is priced by EnvelopeDepartureCost, so the route stays within the gap. + /// + [Fact] + public void RouteWithStatus_SaturatedNarrowGapSoftObstacles_StaysWithinEnvelope() + { + // Arrange: a narrow 24px gap between two boxes (source right edge at X=142, target left edge at + // X=166, mirroring the reported repro geometry), with three already-routed parallel connectors' + // interior segments pre-seeded as soft obstacles: one occupying the gap's natural shared lane, + // and two marching progressively further left — the second sitting right at the source box's + // own edge, the third already behind (inside the X-span of) the source box. + var source = new Point2D(142, 43.5); + var target = new Point2D(166, 187.5); + var softObstacles = new[] + { + new Rect(153, 20, 2, 200), + new Rect(141, 20, 2, 200), + new Rect(129, 20, 2, 200), + }; + + // Act + var result = OrthogonalEdgeRouter.RouteWithStatus( + source, target, obstacles: [], clearance: 12, softObstacles: softObstacles); + + // Assert: a valid route was found and every waypoint stays within the source/target X envelope + // (a small allowance covers the router's own approach stub) — no waypoint lands at or behind + // X=141/129, which sit outside the [142,166] gap the two endpoints actually define. + Assert.False(result.Crossed); + AssertAllSegmentsOrthogonal(result.Waypoints); + const double allowance = 4.0; + Assert.All( + result.Waypoints, + p => Assert.True( + p.X >= 142 - allowance && p.X <= 166 + allowance, + $"Waypoint ({p.X},{p.Y}) lies outside the source/target X envelope [142,166].")); + } + + /// + /// Direct, low-level regression test for the connector-to-connector transversal-crossing fix + /// (see OrthogonalEdgeRouter.SoftObstacleCrossingCount/SoftObstacleCrossingCost), + /// isolated from ConnectorRouter's anchor-distribution logic by calling + /// directly. A single vertical-oriented soft + /// obstacle (thin in X, tall in Y — the shape ConnectorRouter.AddLineObstacles builds for + /// an already-routed connector's vertical trunk segment) is seeded so that it crosses one of two + /// otherwise equally-cheap single-turn "L" routes between the source and target, leaving the + /// other entirely clear — a genuinely bounded-cost, no-extra-turn alternative. Before the fix, + /// SoftObstacleOverlapLength priced this perpendicular crossing identically to a trivial + /// few-pixel parallel overlap (bounded by the obstacle's own ~2px thin dimension, regardless of + /// the crossing move's own length), so the two L-routes tied on cost and the crossing one could + /// be (and was) picked, cutting straight across another connector's trunk; after the fix, the + /// new flat SoftObstacleCrossingCost breaks the tie in favor of the non-crossing route. + /// + [Fact] + public void RouteWithStatus_VerticalOrientedSoftObstacleAcrossOneLRoute_PrefersNonCrossingAlternative() + { + // Arrange: source and target are diagonal from one another, so a bounded-cost, single-turn + // "L" route exists in two equally-Manhattan-length flavors — turn early (go vertical first, + // then horizontal) or turn late (go horizontal first, then vertical) — that differ only in + // which one crosses the seeded obstacle. A single vertical-oriented soft obstacle (thin in X, + // tall in Y — the shape ConnectorRouter.AddLineObstacles builds for an already-routed + // connector's vertical trunk) sits at x = 100 spanning y in [-1, 19], squarely across the + // "turn late" route's y = 0 leg, but entirely clear of the "turn early" route's y = 50 leg. Both + // L-shaped routes cost the same but for the new crossing term, so the fix alone — with no + // length- or turn-penalty advantage needed — decides which one A* prefers. + var source = new Point2D(0, 0); + var target = new Point2D(200, 50); + var softObstacles = new[] { new Rect(100, -1, 2, 20) }; + + // Act + var result = OrthogonalEdgeRouter.RouteWithStatus( + source, target, obstacles: [], clearance: 10, softObstacles: softObstacles); + + // Assert: a valid orthogonal route was found (soft obstacles never hard-block). + Assert.False(result.Crossed); + AssertAllSegmentsOrthogonal(result.Waypoints); + + // Assert: no segment of the route transversally crosses the vertical-oriented obstacle — i.e. + // no horizontal segment spans fully from strictly left of x = [100,102] to strictly right of + // it while sitting at a y within the obstacle's [-1,19] span. This is the concrete check the + // fix exists to satisfy: the two L-shaped routes are otherwise equally cheap, so the crossing + // route must be avoided purely because of the new crossing cost. + for (var i = 0; i + 1 < result.Waypoints.Count; i++) + { + var p1 = result.Waypoints[i]; + var p2 = result.Waypoints[i + 1]; + if (Math.Abs(p1.Y - p2.Y) > 1e-9) + { + // Vertical segment: never transversally crosses another vertical-oriented obstacle. + continue; + } + + var y = p1.Y; + var xLo = Math.Min(p1.X, p2.X); + var xHi = Math.Max(p1.X, p2.X); + var crosses = y > -1 && y < 19 && xLo < 100 && xHi > 102; + Assert.False( + crosses, + $"Segment ({p1.X},{p1.Y})-({p2.X},{p2.Y}) transversally crosses the seeded vertical " + + "soft obstacle instead of taking the equally-cheap non-crossing alternative."); + } + } + /// /// A null source anchor is rejected. /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs index cf25724..bf699a2 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs @@ -2,6 +2,8 @@ // Copyright (c) DemaConsulting. All rights reserved. // +using System.Reflection; + using DemaConsulting.Rendering; using DemaConsulting.Rendering.Abstractions; @@ -976,6 +978,40 @@ public void Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws() Assert.Contains("container boundary", ex.Message, StringComparison.Ordinal); } + /// + /// Proves that a same-scope port edge (both endpoints literally direct members, so it never + /// crosses a container boundary) is routed locally by the leaf algorithm even when one of its + /// endpoint boxes is also touched by an unrelated, genuine box-only cross-container edge. The + /// shared box would otherwise mark the port edge as "conflicted" and promote it into the + /// box-only cross-container router's batch, which has no port concept — a regression this test + /// guards against by asserting the layout succeeds instead of throwing + /// . + /// + [Fact] + public void Apply_SameScopePortEdgeSharesBoxWithUnrelatedCrossContainerEdge_DoesNotThrow() + { + // Arrange: two root-level plain nodes joined by a same-scope port edge (boxA's named port to + // boxB), plus a separate container with a nested child. An unrelated, box-only edge from boxA + // straight to the nested child is a genuine cross-container edge that marks boxA as + // "conflicted" with the port edge's box — but the port edge itself never crosses a boundary. + var graph = new LayoutGraph(); + var boxA = graph.AddNode("boxA", 80, 40); + var port = boxA.Ports.AddPort("out1"); + var boxB = graph.AddNode("boxB", 80, 40); + + var container = graph.AddNode("container", 10, 10); + var nested = container.Children.AddNode("nested", 60, 40); + + graph.AddEdge("same-scope-port", port, boxB); + graph.AddEdge("cross-container", boxA, nested); + + // Act + var result = new HierarchicalLayoutAlgorithm().ApplyCore(graph, new LayoutOptions()); + + // Assert: both edges are routed, none dropped, and no exception is thrown. + Assert.Equal(2, result.Nodes.OfType().Count()); + } + /// /// Proves that a emitted by a nested scope's own leaf pass is /// correctly translated into the ancestor container's absolute coordinates when composed, @@ -1379,6 +1415,195 @@ public void HierarchicalLayoutAlgorithm_ThreeLevelChain_InnermostContainerGrows_ Assert.True(l1.Width >= leafWidth, "Outermost container did not grow to accommodate the deep oversized leaf."); } + /// + /// Proves the no-boundary-port sibling-gap widening: two peer containers placed side by side by + /// the leaf pass and joined by a fan of eight parallel cross-container edges have the gap between + /// them widened to the connector-corridor width so those connectors get distinct routing lanes. + /// + [Fact] + public void Apply_SiblingContainersWithEightCrossEdges_WidensGapToCorridorWidth() + { + // Arrange: two containers, each holding one tall, narrow child, joined by eight parallel + // cross-container edges. Tall/narrow children make the disconnected-component packer place the + // two containers side by side (this graph carries no boundary ports). + var graph = BuildSiblingContainerGraph(crossEdgeCount: 8); + + // Act + var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: the two top-level containers share a vertical band (side by side) and the gap between + // them equals the eight-connector corridor width (2*10 + 7*16 = 132). + var containers = tree.Nodes.OfType().OrderBy(box => box.X).ToList(); + Assert.Equal(2, containers.Count); + Assert.True( + containers[0].Y < containers[1].Y + containers[1].Height && + containers[1].Y < containers[0].Y + containers[0].Height, + "Expected the two containers to be placed side by side (overlapping vertical bands)."); + var gap = containers[1].X - (containers[0].X + containers[0].Width); + Assert.Equal(132.0, gap); + } + + /// + /// Proves a single cross-container edge never widens the sibling gap: the same two side-by-side + /// containers joined by just one cross-container edge keep the un-widened baseline gap, so every + /// existing single-edge scope stays byte-identical (a fan of one needs no extra lane). + /// + [Fact] + public void Apply_SiblingContainersWithSingleCrossEdge_LeavesGapUnwidened() + { + // Arrange: the identical container arrangement, but with only one cross-container edge. + var widenedGraph = BuildSiblingContainerGraph(crossEdgeCount: 8); + var singleEdgeGraph = BuildSiblingContainerGraph(crossEdgeCount: 1); + + // Act + var widened = new HierarchicalLayoutAlgorithm().ApplyCore(widenedGraph, LayoutOptions.ForAlgorithm("layered")); + var single = new HierarchicalLayoutAlgorithm().ApplyCore(singleEdgeGraph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: the single-edge gap is the un-widened baseline and is strictly narrower than the + // eight-edge gap, proving the widening is driven by (and only by) the connector count. + var singleContainers = single.Nodes.OfType().OrderBy(box => box.X).ToList(); + var widenedContainers = widened.Nodes.OfType().OrderBy(box => box.X).ToList(); + var singleGap = singleContainers[1].X - (singleContainers[0].X + singleContainers[0].Width); + var widenedGap = widenedContainers[1].X - (widenedContainers[0].X + widenedContainers[0].Width); + Assert.True(singleGap < widenedGap, "A single cross-container edge must not widen the gap."); + Assert.True(singleGap < 132.0, "The single-edge gap must stay at the un-widened baseline."); + } + + /// + /// Proves the sibling-gap widening shifts an unrelated line's waypoints independently, not as one + /// rigid unit: a line whose waypoints straddle the inserted cut (a common outcome for + /// Sugiyama-style routing threading an unrelated edge through the free channel between two + /// side-by-side boxes) must have only the waypoints at or past the cut shifted, keeping the + /// waypoints before the cut exactly where the leaf pass placed them. + /// + [Fact] + public void WidenSiblingContainerGaps_LineStraddlingCut_ShiftsOnlyWaypointsPastCut() + { + // Arrange: two side-by-side containers with a gap of 20 joined by eight parallel cross-container + // edges (qualifies for widening to the 132-wide corridor, per the eight-edge formula already + // proven above), plus one unrelated placed line whose two waypoints sit either side of the cut + // (at the right box's original left edge, x=120): one waypoint left of it, one to its right. + var graph = new LayoutGraph(); + var left = graph.AddNode("Left", 100, 200); + var right = graph.AddNode("Right", 100, 200); + var indexOf = new Dictionary { [left] = 0, [right] = 1 }; + var descendantToDirect = new Dictionary { [left] = left, [right] = right }; + + var routedEdges = new List(); + for (var i = 0; i < 8; i++) + { + routedEdges.Add(graph.AddEdge($"e{i}", left, right)); + } + + var composed = new[] + { + new LayoutBox(0, 0, 100, 200, "Left", 0, BoxShape.Rectangle, [], []), + new LayoutBox(120, 0, 100, 200, "Right", 0, BoxShape.Rectangle, [], []), + }; + + var straddlingLine = new LayoutLine( + [new Point2D(50, 300), new Point2D(300, 300)], + EndMarkerStyle.None, + EndMarkerStyle.None, + LineStyle.Solid, + null); + var placedLines = new List { straddlingLine }; + var placedPorts = new List(); + + // Act: invoke the private widening method directly so this test exercises the exact geometry + // that exposed the bug, independent of whatever the leaf/component-packer would place in + // practice. + var method = typeof(HierarchicalLayoutAlgorithm).GetMethod( + "WidenSiblingContainerGaps", + BindingFlags.NonPublic | BindingFlags.Static)!; + var result = method.Invoke( + null, + [composed, indexOf, routedEdges, descendantToDirect, placedLines, placedPorts, 250.0]); + var (widenedBoxes, widenedLines, _, _) = ((LayoutBox[], List, List, double))result!; + + // Assert: the gap was actually widened (sanity check the fixture qualifies for widening), and + // the straddling line's waypoint before the cut stayed put while the waypoint past the cut moved + // by the same extra width the right box moved by. + var extra = widenedBoxes[1].X - 120.0; + Assert.True(extra > 0.0, "Fixture must actually qualify for widening for this test to be meaningful."); + + var resultLine = Assert.Single(widenedLines); + Assert.Equal(50.0, resultLine.Waypoints[0].X, precision: 3); + Assert.Equal(300.0, resultLine.Waypoints[0].Y, precision: 3); + Assert.Equal(300.0 + extra, resultLine.Waypoints[1].X, precision: 3); + Assert.Equal(300.0, resultLine.Waypoints[1].Y, precision: 3); + } + + /// + /// Proves the boundary-port path is unaffected by the sibling-gap widening: the widening pass + /// runs only in the no-boundary-port branch, so a scope that owns a boundary port fed by a fan of + /// parallel external approaches still resolves to a single shared anchor through the combined + /// pass, exactly as it did before the widening was added. + /// + [Fact] + public void Apply_BoundaryPortWithParallelApproaches_ResolvesToOneSharedAnchorUnaffectedByWidening() + { + // Arrange: a single boundary port on container B, approached by three parallel external edges + // from one sibling and delegating to one child. The presence of boundary ports forces the whole + // scope onto the combined pass, which the widening pass never touches. + var graph = new LayoutGraph(); + var a = graph.AddNode("A", 80, 40); + var b = graph.AddNode("B", 10, 10); + b.Label = "B"; + var port = b.Ports.AddPort("p1"); + port.ExternalLabel = "PWR_OUT"; + port.InternalLabel = "PWR_IN"; + var c = b.Children.AddNode("C", 80, 40); + + graph.AddEdge("a-b-0", a, port); + graph.AddEdge("a-b-1", a, port); + graph.AddEdge("a-b-2", a, port); + b.Children.AddEdge("b-c", port, c); + + // Act + var tree = new HierarchicalLayoutAlgorithm().ApplyCore(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: exactly one shared anchor carrying both labels, sitting on container B's boundary — + // the combined-pass behaviour, unperturbed by the no-boundary-port widening pass. + var emitted = Assert.Single(tree.Nodes.OfType()); + Assert.Equal("PWR_OUT", emitted.ExternalLabel); + Assert.Equal("PWR_IN", emitted.InternalLabel); + var containerBox = tree.Nodes.OfType().Single(box => box.Label == "B"); + Assert.True( + OnBoxBoundary(emitted.CentreX, emitted.CentreY, containerBox), + "The shared anchor does not lie on container B's boundary."); + } + + /// + /// Builds two peer containers, each holding one tall, narrow compartment-free child, joined by + /// parallel cross-container edges running child-to-child and + /// containing no boundary ports. The tall/narrow children make the disconnected-component + /// packer place the two containers side by side, exercising the no-boundary-port widening pass. + /// + /// The number of parallel child-to-child cross-container edges. + /// The assembled two-container graph. + private static LayoutGraph BuildSiblingContainerGraph(int crossEdgeCount) + { + var graph = new LayoutGraph(); + + var left = graph.AddNode("Left", 10, 10); + left.Label = "Left"; + var leftChild = left.Children.AddNode("LeftChild", 90, 240); + leftChild.Label = "LeftChild"; + + var right = graph.AddNode("Right", 10, 10); + right.Label = "Right"; + var rightChild = right.Children.AddNode("RightChild", 90, 240); + rightChild.Label = "RightChild"; + + for (var i = 0; i < crossEdgeCount; i++) + { + graph.AddEdge($"leftChild-rightChild-{i}", leftChild, rightChild); + } + + return graph; + } + /// Deterministically builds a flat (non-nested) layout graph for the given seed, with random /// node sizes and arbitrary edges (including self-loops, parallel edges, and cycles). /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs index c3f9c75..c44cdc0 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs @@ -63,10 +63,26 @@ public void CreateDefaultRegistry_ResolvesHierarchicalAlgorithm() } /// - /// Proves the default registry contains exactly the three bundled algorithm identifiers. + /// Proves the default registry resolves the bundled auto meta-algorithm by its identifier. /// [Fact] - public void CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms() + public void CreateDefaultRegistry_ResolvesAutoAlgorithm() + { + // Arrange + var registry = LayoutAlgorithms.CreateDefaultRegistry(); + + // Act + var algorithm = registry.Resolve("auto"); + + // Assert + Assert.IsType(algorithm); + } + + /// + /// Proves the default registry contains exactly the four bundled algorithm identifiers. + /// + [Fact] + public void CreateDefaultRegistry_RegistersOnlyTheFourBundledAlgorithms() { // Arrange var registry = LayoutAlgorithms.CreateDefaultRegistry(); @@ -75,7 +91,7 @@ public void CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms() var ids = registry.Ids.OrderBy(id => id, StringComparer.Ordinal).ToArray(); // Assert - Assert.Equal(["containment", "hierarchical", "layered"], ids); + Assert.Equal(["auto", "containment", "hierarchical", "layered"], ids); } /// From 9df7cad131a49578d79dd059ef021ced389f0990 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 15 Jul 2026 16:44:59 -0400 Subject: [PATCH 2/3] Fix broken table in gallery README caused by stray blank lines Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/gallery/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 7431c6c..b593fdb 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -14,9 +14,7 @@ Each group's own page has the full diagram set with detailed captions explaining relevant, which bug it guards against. This page just points the way and shows a taste of each. | Group | What it's about | - | --- | --- | - | [Flow pipeline](flow-pipeline/README.md) | A single connected, directed flow — pipelines and direction changes | | [Connectivity and clusters](connectivity-and-clusters/README.md) | Connected vs. disconnected input and clusters | | [Nested hierarchy](nested-hierarchy/README.md) | Parent/child containment and boundary-port delegation | From ce17de0246f0a207ef020661d4b299c26f946743 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 15 Jul 2026 17:11:55 -0400 Subject: [PATCH 3/3] Drop SonarScanner OTS requirements and documentation for now The Rendering-OTS-SonarScanner requirement had no satisfying test, failing dotnet reqstream --enforce (333 of 334 requirements satisfied). Removes the OTS reqstream/design/verification trio, the requirements.yaml include, the .reviewmark.yaml review-set entry, and the catalog entry in docs/design/ots.md. The actual SonarScanner for .NET tool install and CI usage (dotnet-tools.json, build.yaml, versionmark.yaml) are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 1 - .reviewmark.yaml | 7 ----- docs/design/ots.md | 3 +- docs/design/ots/sonarscanner.md | 25 --------------- docs/reqstream/ots/sonarscanner.yaml | 21 ------------- docs/verification/ots/sonarscanner.md | 44 --------------------------- requirements.yaml | 1 - 7 files changed, 1 insertion(+), 101 deletions(-) delete mode 100644 docs/design/ots/sonarscanner.md delete mode 100644 docs/reqstream/ots/sonarscanner.yaml delete mode 100644 docs/verification/ots/sonarscanner.md diff --git a/.cspell.yaml b/.cspell.yaml index e15a72f..491f207 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -76,7 +76,6 @@ words: - transversal - transversally - unwidened - - sonarscanner # Non-ASCII terms retained verbatim in extracted source comments - façade - Köpf diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 4e6c12e..b47dfa4 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -726,13 +726,6 @@ reviews: - "docs/design/ots/sonarmark.md" - "docs/verification/ots/sonarmark.md" - - id: OTS-SonarScanner - title: Review that SonarScanner for .NET Provides Required Functionality - paths: - - "docs/reqstream/ots/sonarscanner.yaml" - - "docs/design/ots/sonarscanner.md" - - "docs/verification/ots/sonarscanner.md" - - id: OTS-VersionMark title: Review that VersionMark Provides Required Functionality paths: diff --git a/docs/design/ots.md b/docs/design/ots.md index 511fd20..f5a1689 100644 --- a/docs/design/ots.md +++ b/docs/design/ots.md @@ -9,7 +9,7 @@ package. ## OTS Items -The repository integrates twelve OTS items: +The repository integrates eleven OTS items: ```text OTS Software Items @@ -20,7 +20,6 @@ OTS Software Items ├── ReviewMark — file-review plan, report, and enforcement ├── SarifMark — CodeQL SARIF-to-Markdown conversion ├── SkiaSharp — raster graphics library (bitmap drawing and PNG/JPEG/WEBP encoding) -├── SonarScanner for .NET — SonarCloud static-analysis scanner wrapper ├── SonarMark — SonarCloud quality-report generation ├── VersionMark — tool-version capture and publishing ├── WeasyPrint — HTML-to-PDF (PDF/A) conversion diff --git a/docs/design/ots/sonarscanner.md b/docs/design/ots/sonarscanner.md deleted file mode 100644 index 3dbbed8..0000000 --- a/docs/design/ots/sonarscanner.md +++ /dev/null @@ -1,25 +0,0 @@ -## SonarScanner for .NET Integration Design - -### Purpose - -SonarScanner for .NET (the `dotnet-sonarscanner` local tool) wraps the MSBuild-based build with -begin/end analysis steps that collect source metrics, test coverage, and static-analysis issues and -upload them to SonarCloud for the repository's project. It is the analysis producer that SonarMark's -quality report subsequently reads back from SonarCloud. - -### Features Used - -- **Begin analysis** (`dotnet sonarscanner begin --key --organization --token`) — starts an - analysis session before the build, instrumenting subsequent compiler and test runs. -- **End analysis** (`dotnet sonarscanner end --token`) — stops the analysis session after the build - and tests complete, uploading the collected metrics, coverage, and issues to SonarCloud. - -### Integration Pattern - -SonarScanner for .NET is installed as a .NET local tool via `.config/dotnet-tools.json` and invoked -as `dotnet sonarscanner` from the code-quality steps of `.github/workflows/build.yaml`. The begin -step runs before `dotnet build`/`dotnet test`, and the end step runs immediately after, using the -`SONAR_TOKEN` secret for authentication. Its inputs are the SonarCloud project key, organization, -and API token; its output is the analysis published to SonarCloud, which SonarMark then retrieves -to render the offline quality report. SonarScanner for .NET is not referenced by the delivered -Rendering packages. diff --git a/docs/reqstream/ots/sonarscanner.yaml b/docs/reqstream/ots/sonarscanner.yaml deleted file mode 100644 index 37eb552..0000000 --- a/docs/reqstream/ots/sonarscanner.yaml +++ /dev/null @@ -1,21 +0,0 @@ ---- -# SonarScanner for .NET OTS Software Requirements -# -# Requirements for the SonarScanner for .NET static-analysis scanner functionality. - -sections: - - title: OTS Software Requirements - sections: - - title: SonarScanner for .NET Requirements - requirements: - - id: Rendering-OTS-SonarScanner - title: SonarScanner for .NET shall analyze the build and publish results to SonarCloud. - justification: | - The dotnet-sonarscanner local tool wraps the build with begin/end analysis steps that - upload source metrics, coverage, and issues to SonarCloud. It runs in the same CI - pipeline that produces the TRX test results consumed by SonarMark, so a successful - pipeline run is evidence that SonarScanner for .NET executed without error. - tags: [ots] - tests: - - SonarScanner_BeginAnalysis - - SonarScanner_EndAnalysisPublishesResults diff --git a/docs/verification/ots/sonarscanner.md b/docs/verification/ots/sonarscanner.md deleted file mode 100644 index 6f4f854..0000000 --- a/docs/verification/ots/sonarscanner.md +++ /dev/null @@ -1,44 +0,0 @@ -## SonarScanner for .NET Verification - -This document provides the verification evidence for the SonarScanner for .NET OTS software item. -Requirements for this OTS item are defined in the SonarScanner for .NET OTS Software Requirements -document. - -### Required Functionality - -The dotnet-sonarscanner local tool wraps the build with begin/end analysis steps that upload -source metrics, coverage, and issues to SonarCloud. It runs in the same CI pipeline that produces -the TRX test results consumed by SonarMark, so a successful pipeline run is evidence that -SonarScanner for .NET executed without error. - -### Verification Approach - -SonarScanner for .NET is verified by the CI pipeline itself: the pipeline runs -`dotnet sonarscanner begin` before the build and `dotnet sonarscanner end` after tests complete, -uploading the analysis to SonarCloud. A CI build failure at either step is evidence that -SonarScanner for .NET did not analyze and publish the build correctly; a successful pipeline run, -combined with SonarMark's downstream retrieval of the published quality-gate data, is evidence -that the analysis was uploaded correctly. - -### Test Scenarios - -#### SonarScanner_BeginAnalysis - -**Scenario**: The CI pipeline runs `dotnet sonarscanner begin` before the build. - -**Expected**: Exits 0 and prepares the workspace for analysis. - -**Requirement coverage**: `Rendering-OTS-SonarScanner`. - -#### SonarScanner_EndAnalysisPublishesResults - -**Scenario**: The CI pipeline runs `dotnet sonarscanner end` after the build and tests complete. - -**Expected**: Exits 0 and publishes the analysis results to SonarCloud, where SonarMark -subsequently retrieves them. - -**Requirement coverage**: `Rendering-OTS-SonarScanner`. - -### Requirements Coverage - -- **`Rendering-OTS-SonarScanner`**: SonarScanner_BeginAnalysis, SonarScanner_EndAnalysisPublishesResults diff --git a/requirements.yaml b/requirements.yaml index 35de555..53c26ae 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -47,7 +47,6 @@ includes: - docs/reqstream/ots/versionmark.yaml - docs/reqstream/ots/sarifmark.yaml - docs/reqstream/ots/sonarmark.yaml - - docs/reqstream/ots/sonarscanner.yaml - docs/reqstream/ots/reviewmark.yaml - docs/reqstream/ots/pandoc.yaml - docs/reqstream/ots/weasyprint.yaml