diff --git a/.cspell.yaml b/.cspell.yaml index e6115d4..df8867b 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -34,6 +34,42 @@ words: - weasyprint - netstandard - yamlfix + # Rendering library technical terms and referenced code identifiers + - Skia + - SkiaSharp + - Noto + - webp + - rasterizer + - rasterizes + - Sugiyama + - Brandes + - Kopf + - Hanan + - Precomputes + - subfolders + - Rects + - Premul + - dasharray + - RRGGBB + - RRGGBBAA + - vdir + - hdir + - posis + - longedge + - selfloop + - edgerouting + - hierarchyhandling + - facade + - browsable + # Non-ASCII terms retained verbatim in extracted source comments + - façade + - Köpf + # DEMA documentation spelling conventions (shared with sibling repositories) + - behaviour + - colour + - colours + - initialised + - optimised # Exclude common build artifacts, dependencies, and vendored third-party code ignorePaths: @@ -44,6 +80,7 @@ ignorePaths: - "**/third-party/**" - "**/3rd-party/**" - "**/generated/**" + - "src/*/api/**" - "**/AGENT_REPORT_*.md" - "**/.agent-logs/**" - "**/bin/**" diff --git a/.gitignore b/.gitignore index 244fc19..07b3576 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,9 @@ __pycache__/ # Generated documentation **/generated/ +# ApiMark-generated API reference (one api/ folder per library project) +src/*/api/ + # Test results TestResults/ *.trx diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 4942746..64720dd 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -51,5 +51,6 @@ ignores: - "**/third-party/**" - "**/3rd-party/**" - "**/generated/**" + - "src/*/api/**" - "**/AGENT_REPORT_*.md" - "**/.agent-logs/**" diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 0677c0a..0c82d5f 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -35,46 +35,418 @@ reviews: paths: - "README.md" - "docs/user_guide/**/*.md" - - "docs/reqstream/rendering/rendering.yaml" - "docs/design/introduction.md" + - "docs/verification/introduction.md" + # NamespaceDoc.cs files are namespace-level documentation carriers, not + # functional code: each is an `internal static class NamespaceDoc` whose + # XML ApiMark renders as the namespace landing-page description. + # They advertise the packages' capabilities and "start here" orientation, + # so they are reviewed here alongside the other user-facing promises. + # (They are also caught by `needs-review: **/*.cs`; this groups them into + # the correct compliance question.) + - "src/**/NamespaceDoc.cs" + # Per the ReviewMark standard, the Purpose review also carries the system-level + # requirements and system-level design so the advertised capabilities can be + # checked against what each system promises (unit detail is excluded). + - "docs/reqstream/rendering/rendering.yaml" + - "docs/reqstream/rendering-abstractions/rendering-abstractions.yaml" + - "docs/reqstream/rendering-layout/rendering-layout.yaml" + - "docs/reqstream/rendering-svg/rendering-svg.yaml" + - "docs/reqstream/rendering-skia/rendering-skia.yaml" - "docs/design/rendering/rendering.md" + - "docs/design/rendering-abstractions/rendering-abstractions.md" + - "docs/design/rendering-layout/rendering-layout.md" + - "docs/design/rendering-svg/rendering-svg.md" + - "docs/design/rendering-skia/rendering-skia.md" - # Rendering - Specials - - id: Rendering-Architecture - title: Review of Rendering system architecture and operational validation + # ===================================================================== + # Rendering (Model) system + # ===================================================================== + - id: Rendering-Model-Architecture + title: Review that Rendering Model Architecture Satisfies Requirements paths: - "docs/reqstream/rendering/rendering.yaml" - "docs/design/introduction.md" - "docs/design/rendering/rendering.md" - "docs/verification/introduction.md" - "docs/verification/rendering/rendering.md" - - "test/DemaConsulting.Rendering.Tests/RenderingTests.cs" - - id: Rendering-Design - title: Review of Rendering architectural and design consistency + - id: Rendering-Model-Design + title: Review that Rendering Model Design is Consistent and Complete paths: - "docs/reqstream/rendering/rendering.yaml" - "docs/reqstream/rendering/platform-requirements.yaml" - "docs/design/introduction.md" - "docs/design/rendering/**/*.md" - - "docs/verification/introduction.md" - - id: Rendering-AllRequirements - title: Review of Rendering requirements quality and traceability + - id: Rendering-Model-AllRequirements + title: Review that All Rendering Model Requirements are Complete paths: - "requirements.yaml" - "docs/reqstream/rendering/**/*.yaml" - - "docs/reqstream/ots/*.yaml" + - "docs/reqstream/ots/**/*.yaml" + + - id: Rendering-Model-LayoutTree + title: Review that Rendering Model LayoutTree Implementation is Correct + paths: + - "docs/reqstream/rendering/layout-tree.yaml" + - "docs/design/rendering/layout-tree.md" + - "docs/verification/rendering/layout-tree.md" + - "src/DemaConsulting.Rendering/LayoutTree/**/*.cs" + - "src/DemaConsulting.Rendering/Rect.cs" + - "test/DemaConsulting.Rendering.Tests/LayoutTests.cs" + + - id: Rendering-Model-Options + title: Review that Rendering Model Options Implementation is Correct + paths: + - "docs/reqstream/rendering/options.yaml" + - "docs/design/rendering/options.md" + - "docs/verification/rendering/options.md" + - "src/DemaConsulting.Rendering/Options/**/*.cs" + - "test/DemaConsulting.Rendering.Tests/PropertyHolderTests.cs" + + - id: Rendering-Model-LayoutGraph + title: Review that Rendering Model LayoutGraph Implementation is Correct + paths: + - "docs/reqstream/rendering/layout-graph.yaml" + - "docs/design/rendering/layout-graph.md" + - "docs/verification/rendering/layout-graph.md" + - "src/DemaConsulting.Rendering/Graph/**/*.cs" + - "test/DemaConsulting.Rendering.Tests/LayoutGraphTests.cs" + + # ===================================================================== + # Rendering.Abstractions system + # ===================================================================== + - id: Rendering-Abstractions-Architecture + title: Review that Rendering Abstractions Architecture Satisfies Requirements + paths: + - "docs/reqstream/rendering-abstractions/rendering-abstractions.yaml" + - "docs/design/introduction.md" + - "docs/design/rendering-abstractions/rendering-abstractions.md" + - "docs/verification/introduction.md" + - "docs/verification/rendering-abstractions/rendering-abstractions.md" + + - id: Rendering-Abstractions-Design + title: Review that Rendering Abstractions Design is Consistent and Complete + paths: + - "docs/reqstream/rendering-abstractions/rendering-abstractions.yaml" + - "docs/design/introduction.md" + - "docs/design/rendering-abstractions/**/*.md" + + - id: Rendering-Abstractions-AllRequirements + title: Review that All Rendering Abstractions Requirements are Complete + paths: + - "requirements.yaml" + - "docs/reqstream/rendering-abstractions/**/*.yaml" + - "docs/reqstream/ots/**/*.yaml" + + - id: Rendering-Abstractions-RenderingContracts + title: Review that Rendering Abstractions RenderingContracts Implementation is Correct + paths: + - "docs/reqstream/rendering-abstractions/rendering-contracts.yaml" + - "docs/design/rendering-abstractions/rendering-contracts.md" + - "docs/verification/rendering-abstractions/rendering-contracts.md" + - "src/DemaConsulting.Rendering.Abstractions/ILayoutAlgorithm.cs" + - "src/DemaConsulting.Rendering.Abstractions/IRenderer.cs" + - "src/DemaConsulting.Rendering.Abstractions/RenderOptions.cs" + - "src/DemaConsulting.Rendering.Abstractions/RenderOutput.cs" + - "test/DemaConsulting.Rendering.Abstractions.Tests/RegistryTests.cs" + + - id: Rendering-Abstractions-Registries + title: Review that Rendering Abstractions Registries Implementation is Correct + paths: + - "docs/reqstream/rendering-abstractions/registries.yaml" + - "docs/design/rendering-abstractions/registries.md" + - "docs/verification/rendering-abstractions/registries.md" + - "src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmRegistry.cs" + - "src/DemaConsulting.Rendering.Abstractions/RendererRegistry.cs" + - "test/DemaConsulting.Rendering.Abstractions.Tests/RegistryTests.cs" + + - id: Rendering-Abstractions-Theme + title: Review that Rendering Abstractions Theme Implementation is Correct + paths: + - "docs/reqstream/rendering-abstractions/theme.yaml" + - "docs/design/rendering-abstractions/theme.md" + - "docs/verification/rendering-abstractions/theme.md" + - "src/DemaConsulting.Rendering.Abstractions/Theme.cs" + - "test/DemaConsulting.Rendering.Abstractions.Tests/ThemeTests.cs" + + - id: Rendering-Abstractions-NotationMetrics + title: Review that Rendering Abstractions NotationMetrics Implementation is Correct + paths: + - "docs/reqstream/rendering-abstractions/notation-metrics.yaml" + - "docs/design/rendering-abstractions/notation-metrics.md" + - "docs/verification/rendering-abstractions/notation-metrics.md" + - "src/DemaConsulting.Rendering.Abstractions/NotationMetrics.cs" + - "test/DemaConsulting.Rendering.Abstractions.Tests/NotationMetricsTests.cs" + + - id: Rendering-Abstractions-BoxMetrics + title: Review that Rendering Abstractions BoxMetrics Implementation is Correct + paths: + - "docs/reqstream/rendering-abstractions/box-metrics.yaml" + - "docs/design/rendering-abstractions/box-metrics.md" + - "docs/verification/rendering-abstractions/box-metrics.md" + - "src/DemaConsulting.Rendering.Abstractions/BoxMetrics.cs" + - "test/DemaConsulting.Rendering.Abstractions.Tests/BoxMetricsTests.cs" + + - id: Rendering-Abstractions-ConnectorLabelPlacer + title: Review that Rendering Abstractions ConnectorLabelPlacer Implementation is Correct + paths: + - "docs/reqstream/rendering-abstractions/connector-label-placer.yaml" + - "docs/design/rendering-abstractions/connector-label-placer.md" + - "docs/verification/rendering-abstractions/connector-label-placer.md" + - "src/DemaConsulting.Rendering.Abstractions/ConnectorLabelPlacer.cs" + - "test/DemaConsulting.Rendering.Abstractions.Tests/ConnectorLabelPlacerTests.cs" + + # ===================================================================== + # Rendering.Layout system (contains the Engine subsystem) + # ===================================================================== + - id: Rendering-Layout-Architecture + title: Review that Rendering Layout Architecture Satisfies Requirements + paths: + - "docs/reqstream/rendering-layout/rendering-layout.yaml" + - "docs/design/introduction.md" + - "docs/design/rendering-layout/rendering-layout.md" + - "docs/verification/introduction.md" + - "docs/verification/rendering-layout/rendering-layout.md" + + - id: Rendering-Layout-Design + title: Review that Rendering Layout Design is Consistent and Complete + paths: + - "docs/reqstream/rendering-layout/rendering-layout.yaml" + - "docs/design/introduction.md" + - "docs/design/rendering-layout/**/*.md" + + - id: Rendering-Layout-AllRequirements + title: Review that All Rendering Layout Requirements are Complete + paths: + - "requirements.yaml" + - "docs/reqstream/rendering-layout/**/*.yaml" + - "docs/reqstream/ots/**/*.yaml" + + # Engine subsystem (excludes unit source; only its units below carry source) + - id: Rendering-Layout-Engine + title: Review that Rendering Layout Engine Satisfies Subsystem Requirements + paths: + - "docs/reqstream/rendering-layout/engine/engine.yaml" + - "docs/design/rendering-layout/engine/engine.md" + - "docs/verification/rendering-layout/engine/engine.md" + + - id: Rendering-Layout-Engine-OrthogonalEdgeRouter + title: Review that Rendering Layout Engine OrthogonalEdgeRouter Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml" + - "docs/design/rendering-layout/engine/orthogonal-edge-router.md" + - "docs/verification/rendering-layout/engine/orthogonal-edge-router.md" + - "src/DemaConsulting.Rendering.Layout/Engine/OrthogonalEdgeRouter.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs" + + - id: Rendering-Layout-Engine-ContainmentPacker + title: Review that Rendering Layout Engine ContainmentPacker Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/engine/containment-packer.yaml" + - "docs/design/rendering-layout/engine/containment-packer.md" + - "docs/verification/rendering-layout/engine/containment-packer.md" + - "src/DemaConsulting.Rendering.Layout/Engine/ContainmentPacker.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/Engine/ContainmentPackerTests.cs" + + - id: Rendering-Layout-Engine-InterconnectionLayoutEngine + title: Review that Rendering Layout Engine InterconnectionLayoutEngine Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/engine/interconnection-layout-engine.yaml" + - "docs/design/rendering-layout/engine/interconnection-layout-engine.md" + - "docs/verification/rendering-layout/engine/interconnection-layout-engine.md" + - "src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/Engine/InterconnectionLayoutEngineTests.cs" + + - id: Rendering-Layout-Engine-LayeredPipeline + title: Review that Rendering Layout Engine LayeredPipeline Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/engine/layered-pipeline.yaml" + - "docs/design/rendering-layout/engine/layered-pipeline.md" + - "docs/verification/rendering-layout/engine/layered-pipeline.md" + - "src/DemaConsulting.Rendering.Layout/Engine/Layered/**/*.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/**/*.cs" + + # Rendering.Layout system-level units + - id: Rendering-Layout-LayeredLayoutAlgorithm + title: Review that Rendering Layout LayeredLayoutAlgorithm Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/layered-layout-algorithm.yaml" + - "docs/design/rendering-layout/layered-layout-algorithm.md" + - "docs/verification/rendering-layout/layered-layout-algorithm.md" + - "src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs" + + - id: Rendering-Layout-ConnectorRouter + title: Review that Rendering Layout ConnectorRouter Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/connector-router.yaml" + - "docs/design/rendering-layout/connector-router.md" + - "docs/verification/rendering-layout/connector-router.md" + - "src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs" + + # The EdgeRouting option unit realizes the CoreOptions.EdgeRouting behavior. The + # EdgeRouting enum itself is declared in the Rendering model (covered by the model + # Options unit) and consumed by ConnectorRouter (covered above); this unit carries + # the option-realization requirements/design/verification and its own tests. + - id: Rendering-Layout-EdgeRoutingOption + title: Review that Rendering Layout EdgeRoutingOption Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/edge-routing-option.yaml" + - "docs/design/rendering-layout/edge-routing-option.md" + - "docs/verification/rendering-layout/edge-routing-option.md" + - "test/DemaConsulting.Rendering.Layout.Tests/EdgeRoutingOptionTests.cs" + + - id: Rendering-Layout-ContainmentLayout + title: Review that Rendering Layout ContainmentLayout Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/containment-layout.yaml" + - "docs/design/rendering-layout/containment-layout.md" + - "docs/verification/rendering-layout/containment-layout.md" + - "src/DemaConsulting.Rendering.Layout/ContainmentLayout.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs" + + - id: Rendering-Layout-ContainmentLayoutAlgorithm + title: Review that Rendering Layout ContainmentLayoutAlgorithm Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/containment-layout-algorithm.yaml" + - "docs/design/rendering-layout/containment-layout-algorithm.md" + - "docs/verification/rendering-layout/containment-layout-algorithm.md" + - "src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs" + + - id: Rendering-Layout-HierarchicalLayoutAlgorithm + title: Review that Rendering Layout HierarchicalLayoutAlgorithm Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml" + - "docs/design/rendering-layout/hierarchical-layout-algorithm.md" + - "docs/verification/rendering-layout/hierarchical-layout-algorithm.md" + - "src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs" + + - id: Rendering-Layout-DefaultLayout + title: Review that Rendering Layout DefaultLayout Implementation is Correct + paths: + - "docs/reqstream/rendering-layout/default-layout.yaml" + - "docs/design/rendering-layout/default-layout.md" + - "docs/verification/rendering-layout/default-layout.md" + - "src/DemaConsulting.Rendering.Layout/LayoutEngine.cs" + - "src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs" + - "test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs" + + # ===================================================================== + # Rendering.Svg system + # ===================================================================== + - id: Rendering-Svg-Architecture + title: Review that Rendering Svg Architecture Satisfies Requirements + paths: + - "docs/reqstream/rendering-svg/rendering-svg.yaml" + - "docs/design/introduction.md" + - "docs/design/rendering-svg/rendering-svg.md" + - "docs/verification/introduction.md" + - "docs/verification/rendering-svg/rendering-svg.md" + + - id: Rendering-Svg-Design + title: Review that Rendering Svg Design is Consistent and Complete + paths: + - "docs/reqstream/rendering-svg/rendering-svg.yaml" + - "docs/design/introduction.md" + - "docs/design/rendering-svg/**/*.md" + + - id: Rendering-Svg-AllRequirements + title: Review that All Rendering Svg Requirements are Complete + paths: + - "requirements.yaml" + - "docs/reqstream/rendering-svg/**/*.yaml" + - "docs/reqstream/ots/**/*.yaml" + + - id: Rendering-Svg-SvgRenderer + title: Review that Rendering Svg SvgRenderer Implementation is Correct + paths: + - "docs/reqstream/rendering-svg/svg-renderer.yaml" + - "docs/design/rendering-svg/svg-renderer.md" + - "docs/verification/rendering-svg/svg-renderer.md" + - "src/DemaConsulting.Rendering.Svg/SvgRenderer.cs" + - "test/DemaConsulting.Rendering.Svg.Tests/**/*.cs" + + # ===================================================================== + # Rendering.Skia system + # ===================================================================== + - id: Rendering-Skia-Architecture + title: Review that Rendering Skia Architecture Satisfies Requirements + paths: + - "docs/reqstream/rendering-skia/rendering-skia.yaml" + - "docs/design/introduction.md" + - "docs/design/rendering-skia/rendering-skia.md" + - "docs/verification/introduction.md" + - "docs/verification/rendering-skia/rendering-skia.md" + + - id: Rendering-Skia-Design + title: Review that Rendering Skia Design is Consistent and Complete + paths: + - "docs/reqstream/rendering-skia/rendering-skia.yaml" + - "docs/design/introduction.md" + - "docs/design/rendering-skia/**/*.md" + + - id: Rendering-Skia-AllRequirements + title: Review that All Rendering Skia Requirements are Complete + paths: + - "requirements.yaml" + - "docs/reqstream/rendering-skia/**/*.yaml" + - "docs/reqstream/ots/**/*.yaml" + + - id: Rendering-Skia-SkiaRasterRenderer + title: Review that Rendering Skia SkiaRasterRenderer Implementation is Correct + paths: + - "docs/reqstream/rendering-skia/skia-raster-renderer.yaml" + - "docs/design/rendering-skia/skia-raster-renderer.md" + - "docs/verification/rendering-skia/skia-raster-renderer.md" + - "src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs" + - "test/DemaConsulting.Rendering.Skia.Tests/PngRendererPortedTests.cs" + - "test/DemaConsulting.Rendering.Skia.Tests/PngEndMarkerTests.cs" + + - id: Rendering-Skia-PngRenderer + title: Review that Rendering Skia PngRenderer Implementation is Correct + paths: + - "docs/reqstream/rendering-skia/png-renderer.yaml" + - "docs/design/rendering-skia/png-renderer.md" + - "docs/verification/rendering-skia/png-renderer.md" + - "src/DemaConsulting.Rendering.Skia/PngRenderer.cs" + - "test/DemaConsulting.Rendering.Skia.Tests/PngRendererTests.cs" + - "test/DemaConsulting.Rendering.Skia.Tests/SkiaFormatRendererTests.cs" + + - id: Rendering-Skia-JpegRenderer + title: Review that Rendering Skia JpegRenderer Implementation is Correct + paths: + - "docs/reqstream/rendering-skia/jpeg-renderer.yaml" + - "docs/design/rendering-skia/jpeg-renderer.md" + - "docs/verification/rendering-skia/jpeg-renderer.md" + - "src/DemaConsulting.Rendering.Skia/JpegRenderer.cs" + - "test/DemaConsulting.Rendering.Skia.Tests/SkiaFormatRendererTests.cs" + + - id: Rendering-Skia-WebpRenderer + title: Review that Rendering Skia WebpRenderer Implementation is Correct + paths: + - "docs/reqstream/rendering-skia/webp-renderer.yaml" + - "docs/design/rendering-skia/webp-renderer.md" + - "docs/verification/rendering-skia/webp-renderer.md" + - "src/DemaConsulting.Rendering.Skia/WebpRenderer.cs" + - "test/DemaConsulting.Rendering.Skia.Tests/SkiaFormatRendererTests.cs" - # Rendering - Demo - - id: Rendering-Demo - title: Review of Demo Unit Implementation + # Rendering gallery showcase + - id: Rendering-Gallery + title: Review of the rendering gallery showcase / smoke tests paths: - - "docs/reqstream/rendering/demo.yaml" - - "docs/design/rendering/demo.md" - - "docs/verification/rendering/demo.md" - - "src/DemaConsulting.Rendering/Demo.cs" - - "test/DemaConsulting.Rendering.Tests/DemoTests.cs" + # The gallery project's C# files are caught by `needs-review: **/*.cs`, so + # they are grouped here into their own compliance question. They are + # showcase/smoke tests, not requirement-verification tests: each fact + # renders one curated diagram through the public API and asserts the output + # image is valid. The generated images and index under docs/gallery/** are + # not in `needs-review` and are intentionally left out of this set. + - "test/DemaConsulting.Rendering.Gallery/**/*.cs" # OTS Items - id: OTS-BuildMark diff --git a/README.md b/README.md index 31f606e..0d90452 100644 --- a/README.md +++ b/README.md @@ -9,52 +9,81 @@ [![Security][badge-security]][link-security] [![NuGet][badge-nuget]][link-nuget] -DEMA Consulting rendering library. +General-purpose diagram layout and rendering for .NET. Describe a diagram as a graph, lay it out +with a pluggable algorithm, and render it to SVG, PNG, JPEG, or WEBP. The design is inspired by the +[Eclipse Layout Kernel (ELK)][link-elk]: layout and rendering are configured through an open, +extensible property system, and new algorithms, renderers, and options are added additively. -## Features +## Packages -This template demonstrates: +The library is split into focused packages so consumers take only what they need: -- **Simple Library Structure**: Demo class with example methods -- **Multi-Platform Support**: Builds and runs on Windows, Linux, and macOS -- **Multi-Runtime Support**: Targets .NET Standard 2.0, .NET 8, 9, and 10 -- **xUnit v3**: Modern unit testing with xUnit framework version 3 -- **Comprehensive CI/CD**: GitHub Actions workflows with quality checks and builds -- **Linting Enforcement**: markdownlint, cspell, and yamllint enforced on every CI run -- **Continuous Compliance**: Compliance evidence generated automatically on every CI run, following - the [Continuous Compliance][link-continuous-compliance] methodology -- **SonarCloud Integration**: Quality gate and security analysis on every build -- **Documentation Generation**: Automated build notes, user guide, code quality reports, - requirements, justifications, and trace matrix -- **Requirements Traceability**: Requirements linked to passing tests with auto-generated trace matrix +| Package | Purpose | +| --- | --- | +| `DemaConsulting.Rendering` | Layout model: the `LayoutTree` IR, the property system, and the input `LayoutGraph` | +| `DemaConsulting.Rendering.Abstractions` | SPI: `ILayoutAlgorithm`/`IRenderer`, registries, `Theme`, notation metrics | +| `DemaConsulting.Rendering.Layout` | Layout algorithms: the layered pipeline and `LayeredLayoutAlgorithm` | +| `DemaConsulting.Rendering.Svg` | SVG renderer with zero external dependencies | +| `DemaConsulting.Rendering.Skia` | SkiaSharp raster renderers (PNG, JPEG, WEBP) with an embedded Noto Sans font | -## Installation +Package dependencies form an acyclic graph: `Abstractions` and `Layout` depend on the model; +`Svg` and `Skia` depend on the model and `Abstractions`; the model depends on nothing. -Install the library using the .NET CLI: +## Installation ```bash -dotnet add package DemaConsulting.Rendering +dotnet add package DemaConsulting.Rendering.Layout +dotnet add package DemaConsulting.Rendering.Svg ``` ## Usage ```csharp +using System.IO; using DemaConsulting.Rendering; - -var demo = new Demo(); -var result = demo.DemoMethod("World"); // result = "Hello, World!" +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout; +using DemaConsulting.Rendering.Svg; + +// Describe the diagram as a graph of sized boxes and directed edges. +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); + +// Lay it out, then render the placed tree to SVG. +var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); +using var output = File.Create("diagram.svg"); +new SvgRenderer().Render(tree, new RenderOptions(Themes.Light), output); ``` -## Documentation +See the [user guide][link-user-guide] for configuration options and extension points. + +## API Reference + +Every package ships its full API reference as compact, AI-friendly Markdown in an `api/` folder +inside the NuGet package, generated from the XML documentation comments by ApiMark. Start at +`api/api.md` (the index) and drill into the per-namespace and per-type pages for signatures, +remarks, and usage examples. -Generated documentation includes: +## Gallery + +A [rendering gallery][link-gallery] showcases what the library can produce — every bundled layout +algorithm, orthogonal edge routing, all three themes, and both the SVG and PNG output paths. Each +image is generated by the gallery test project directly from the public API, so the gallery doubles +as an end-to-end rendering smoke test. Regenerate it with `./gallery.ps1`. + +## Extensibility + +- **New layout algorithms** implement `ILayoutAlgorithm` and register under a new id. +- **New output formats** implement `IRenderer` with a distinct media type. +- **New configuration options** are declared as typed `LayoutProperty` keys carried in an open + property bag, so adding an option never changes an existing method signature. + +## Documentation -- **Build Notes**: Release information and changes -- **User Guide**: Comprehensive usage documentation -- **Code Quality Report**: CodeQL and SonarCloud analysis results -- **Requirements**: Functional and non-functional requirements -- **Requirements Justifications**: Detailed requirement rationale -- **Trace Matrix**: Requirements to test traceability +Generated documentation includes build notes, a user guide, a code quality report, requirements, +requirement justifications, and a requirements-to-test trace matrix. ## Contributing @@ -86,5 +115,7 @@ By contributing to this project, you agree that your contributions will be licen [link-quality]: https://sonarcloud.io/dashboard?id=demaconsulting_Rendering [link-security]: https://sonarcloud.io/dashboard?id=demaconsulting_Rendering [link-nuget]: https://www.nuget.org/packages/DemaConsulting.Rendering -[link-continuous-compliance]: https://github.com/demaconsulting/ContinuousCompliance +[link-elk]: https://eclipse.dev/elk/ +[link-gallery]: https://github.com/demaconsulting/Rendering/blob/main/docs/gallery/gallery.md +[link-user-guide]: https://github.com/demaconsulting/Rendering/blob/main/docs/user_guide/introduction.md [link-contributing]: https://github.com/demaconsulting/Rendering/blob/main/CONTRIBUTING.md diff --git a/Rendering.slnx b/Rendering.slnx index ff3295e..bc1a135 100644 --- a/Rendering.slnx +++ b/Rendering.slnx @@ -1,8 +1,17 @@ + + + + + + + + + diff --git a/docs/design/definition.yaml b/docs/design/definition.yaml index df844a7..2a52644 100644 --- a/docs/design/definition.yaml +++ b/docs/design/definition.yaml @@ -6,7 +6,10 @@ input-files: - docs/design/title.txt - docs/design/introduction.md - docs/design/rendering/rendering.md - - docs/design/rendering/demo.md + - docs/design/rendering-abstractions/rendering-abstractions.md + - docs/design/rendering-layout/rendering-layout.md + - docs/design/rendering-svg/rendering-svg.md + - docs/design/rendering-skia/rendering-skia.md template: template.html table-of-contents: true number-sections: true diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 479ff8a..ac0e48a 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -1,89 +1,168 @@ # Introduction -This document provides the detailed design for the Rendering, a .NET library -demonstrating best practices for DEMA Consulting DotNet Libraries. +This document provides the detailed design for the Rendering libraries, a set of +general-purpose .NET packages that lay out and render node-and-edge diagrams. The design +is inspired by the [Eclipse Layout Kernel (ELK)](https://eclipse.dev/elk/): diagrams are +described as a graph, laid out by a pluggable algorithm, and drawn by a pluggable renderer, +all configured through an open, extensible property system. ## Purpose -The purpose of this document is to serve as the design entry point and provide detailed design -specifications for the Rendering system. This documentation enables formal code -review by providing implementation specifications, supports compliance auditing by maintaining -clear traceability from requirements through design to code, aids maintenance by documenting -system structure and interactions, and ensures quality assurance through detailed technical -specifications. +The purpose of this document is to serve as the design entry point and to provide detailed +design specifications for each software item across the five Rendering systems. It enables +formal code review by providing implementation specifications, supports compliance auditing +by maintaining clear traceability from requirements through design to code, and aids +maintenance by documenting system structure and interactions. This document is intended for: -- Software developers implementing and maintaining the system +- Software developers implementing and maintaining the libraries - Code reviewers validating implementation against design - Compliance auditors tracing requirements through design to implementation -- Quality assurance teams validating system behavior +- Quality assurance teams validating library behavior ## Scope -This document covers the detailed design of the Rendering system and its constituent -software items, specifically: +This document covers the detailed design of the five Rendering systems and their constituent +software items: -- **Rendering (System)** — The complete .NET library template system -- **Demo (Unit)** — Demonstration greeting class providing example functionality +- **Rendering (System)** — the layout model: the `LayoutTree` intermediate representation, + the open property system, and the input `LayoutGraph`. +- **Rendering.Abstractions (System)** — the service-provider interfaces: the `ILayoutAlgorithm` + and `IRenderer` contracts, their registries, the `Theme`, render options, and the shared + 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 + layout facade. +- **Rendering.Svg (System)** — the SVG renderer. +- **Rendering.Skia (System)** — the SkiaSharp raster renderers (PNG, JPEG, and WEBP). -Version applicability: This design applies to all versions of the Rendering. - -The following topics are explicitly excluded from this design documentation: +The following topics are explicitly excluded: - External library internals and third-party OTS components - Build pipeline configuration and CI/CD processes - Deployment, packaging, and distribution mechanisms -- Infrastructure and hosting environment details - Test projects and test infrastructure -## Software Structure +## Architectural Overview -The following tree diagram shows how the Rendering software items are organized -across System, Subsystem, and Unit levels according to software-items classification standards: +The libraries separate *what a diagram contains* from *how it is placed* and *how it is drawn*: ```text -Rendering (System) -└── Demo (Unit) +LayoutGraph + LayoutOptions (Rendering: unplaced input + open property configuration) + │ + ▼ ILayoutAlgorithm (Rendering.Abstractions: the layout SPI) + LayeredLayoutAlgorithm (Rendering.Layout: the bundled "layered" algorithm) + │ + ▼ + LayoutTree (Rendering: placed boxes and routed connectors) + │ + ▼ IRenderer (Rendering.Abstractions: the render SPI) + SvgRenderer / PngRenderer / JpegRenderer / WebpRenderer (Rendering.Svg / Rendering.Skia) + │ + ▼ + SVG, PNG, JPEG, or WEBP output ``` -This template demonstrates a minimal system structure with no subsystems — it contains only the -`Demo` unit directly under the system level. In more complex implementations, subsystems would -organize related units and provide architectural boundaries with well-defined interfaces and -responsibilities. +Configuration is **open** and **property-based**: options are declared as typed +`LayoutProperty` keys and carried on any `IPropertyHolder` (the graph, a graph element, or a +free-standing `LayoutOptions`). Algorithms and renderers read only the properties they +understand, so unknown or not-yet-honored properties default harmlessly. New diagram families +and output formats are introduced additively by implementing `ILayoutAlgorithm` or `IRenderer` +and registering them — no existing contract changes. -## Companion Artifact Structure +The delivered subset implements the `layered`, `containment`, and `hierarchical` algorithms and the +SVG and SkiaSharp (PNG, JPEG, WEBP) renderers. -Each software item has corresponding artifacts in parallel directory trees: +## Software Structure + +The following tree shows how the Rendering software items are organized across System, +Subsystem, and Unit levels according to the software-items classification standard: ```text Rendering (System) -└── Demo (Unit) +├── LayoutTree (Unit) — immutable placed intermediate representation records +├── Options (Unit) — open property system (LayoutProperty, IPropertyHolder, LayoutOptions, CoreOptions) +└── LayoutGraph (Unit) — unplaced input graph model + +Rendering.Abstractions (System) +├── RenderingContracts (Unit) — ILayoutAlgorithm, IRenderer, RenderOptions, RenderOutput +├── Registries (Unit) — LayoutAlgorithmRegistry, RendererRegistry +├── Theme (Unit) — Theme record and built-in Themes +├── NotationMetrics (Unit) — intrinsic notation geometry shared by renderers +├── BoxMetrics (Unit) — box title-area and folder-tab geometry +└── ConnectorLabelPlacer (Unit) — collision-aware connector-label placement + +Rendering.Layout (System) +├── Engine (Subsystem) +│ ├── 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 +├── LayeredLayoutAlgorithm (Unit) — the public layered ILayoutAlgorithm +├── ContainmentLayoutAlgorithm (Unit) — the public containment ILayoutAlgorithm +├── HierarchicalLayoutAlgorithm (Unit) — the recursive hierarchical engine +├── ContainmentLayout (Unit) — public containment packing entry point +├── ConnectorRouter (Unit) — public edge-routing orchestration +├── EdgeRoutingOption (Unit) — the EdgeRouting option realization +└── DefaultLayout (Unit) — LayoutEngine facade + default algorithm registry + +Rendering.Svg (System) +└── SvgRenderer (Unit) + +Rendering.Skia (System) +├── SkiaRasterRenderer (Unit) — abstract SkiaSharp rasterizer shared by all formats +├── PngRenderer (Unit) — lossless PNG output +├── JpegRenderer (Unit) — JPEG output +└── WebpRenderer (Unit) — WEBP output ``` -Each software item has artifacts in these parallel locations: +Package dependencies form an acyclic graph: `Abstractions` and `Layout` depend on the +`Rendering` model; `Svg` and `Skia` depend on the model and `Abstractions`; the model depends +on nothing. -- Requirements: `docs/reqstream/{system}/.../{item}.yaml` (kebab-case) -- Design docs: `docs/design/{system}/.../{item}.md` (kebab-case) -- Verification design: `docs/verification/{system}/.../{item}.md` (kebab-case) +## Companion Artifact Structure + +Each software item has corresponding artifacts in parallel directory trees. Each system is +decomposed into a slim system-level file plus one file per unit (and per subsystem where one +exists, such as the Layout `engine/` subsystem), so a system-level review excludes unit detail and +each unit review carries only its own slice: + +- Requirements: `docs/reqstream/{system}/{system}.yaml` (system-level) plus + `docs/reqstream/{system}/[{subsystem}/]{unit}.yaml` per unit (kebab-case) +- Design docs: `docs/design/{system}/{system}.md` plus `docs/design/{system}/[{subsystem}/]{unit}.md` +- Verification design: `docs/verification/{system}/{system}.md` plus + `docs/verification/{system}/[{subsystem}/]{unit}.md` - Source code: `src/{System}/.../{Item}.cs` (PascalCase for C#) - Tests: `test/{System}.Tests/.../{Item}Tests.cs` (PascalCase for C#) - Review-sets: defined in `.reviewmark.yaml` -## Folder Layout +The five systems map to these kebab-case folders: -The source code folder structure mirrors the software structure organization, with file paths -and descriptions as follows: +| NuGet Package | kebab-case system folder | +| --- | --- | +| `DemaConsulting.Rendering` | `rendering` | +| `DemaConsulting.Rendering.Abstractions` | `rendering-abstractions` | +| `DemaConsulting.Rendering.Layout` | `rendering-layout` | +| `DemaConsulting.Rendering.Svg` | `rendering-svg` | +| `DemaConsulting.Rendering.Skia` | `rendering-skia` | + +## Folder Layout ```text -src/DemaConsulting.Rendering/ -└── Demo.cs — Demonstration greeting class implementing template functionality +src/ +├── DemaConsulting.Rendering/ — model: IR, property system, input graph +│ ├── LayoutTree/ — LayoutTree and node records +│ ├── Options/ — property system and CoreOptions +│ └── Graph/ — LayoutGraph input model +├── DemaConsulting.Rendering.Abstractions/ — SPI contracts, registries, theme, metrics +├── DemaConsulting.Rendering.Layout/ — layout algorithms +│ └── Engine/Layered/ — ELK-style layered pipeline stages +├── DemaConsulting.Rendering.Svg/ — SVG renderer +└── DemaConsulting.Rendering.Skia/ — SkiaSharp raster renderers (PNG, JPEG, WEBP; Noto Sans) ``` -This flat folder structure reflects the single-unit nature of this template system. As the system -grows with additional subsystems and units, the folder structure will expand to mirror the -software architecture with subsystem-specific folders containing their respective units. - ## Document Conventions Throughout this document: @@ -98,3 +177,4 @@ Throughout this document: - [REF-1] Rendering User Guide () - [REF-2] Rendering Repository () +- [REF-3] Eclipse Layout Kernel () diff --git a/docs/design/rendering-abstractions/box-metrics.md b/docs/design/rendering-abstractions/box-metrics.md new file mode 100644 index 0000000..e3de445 --- /dev/null +++ b/docs/design/rendering-abstractions/box-metrics.md @@ -0,0 +1,39 @@ +# Box Metrics Unit Design + +Part of the Rendering Abstractions system. + +## Box Metrics Overview + +The Box Metrics unit provides the shared formulas that compute a box's title-area height and +folder-tab height from a `Theme`, so that the space the layout strategies reserve equals the space the +renderers draw. + +## Box Metrics Data Model + +- `BoxMetrics` (static class) — `FolderTabHeight(Theme)` and `TitleAreaHeight(Theme, bool, bool)`. + +## Box Metrics Key Methods + +`double FolderTabHeight(Theme theme)` — returns `theme.FontSizeBody + 2 * theme.LabelPadding`. + +`double TitleAreaHeight(Theme theme, bool hasLabel, bool hasKeyword)` — returns the vertical space +reserved at the top of a box: zero when the box has neither a name nor a keyword; otherwise a leading +padding plus, conditionally, a keyword line and a name line, each followed by a padding. + +## Box Metrics Design Constraints + +- `TitleAreaHeight` shall reserve no space when a box has neither a name label nor a keyword line. +- Both the layout strategies and the renderers shall compute box title and folder-tab heights from + these formulas, so reserved space and drawn space always agree. + +## Box Metrics Interactions + +`BoxMetrics` reads `Theme.FontSizeBody`, `FontSizeTitle`, and `LabelPadding`. It is called by the +renderers (SVG and PNG systems) and by the box layout strategies (*Rendering Layout* system). + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Abstractions-BoxMetrics-FolderTabHeight | `BoxMetrics.FolderTabHeight` | +| Rendering-Abstractions-BoxMetrics-TitleAreaHeight | `BoxMetrics.TitleAreaHeight` | diff --git a/docs/design/rendering-abstractions/connector-label-placer.md b/docs/design/rendering-abstractions/connector-label-placer.md new file mode 100644 index 0000000..43010b5 --- /dev/null +++ b/docs/design/rendering-abstractions/connector-label-placer.md @@ -0,0 +1,42 @@ +# Connector Label Placer Unit Design + +Part of the Rendering Abstractions system. + +## Connector Label Placer Overview + +The Connector Label Placer unit computes non-overlapping screen positions for connector midpoint +labels. Each labelled line prefers the midpoint of its longest segment; when two labels would collide, +the placer falls back to a shorter segment or nudges the label perpendicular to its segment until it no +longer overlaps an already-placed label. Lines are processed in the supplied order so the result is +deterministic, and both renderers share this logic so their label layouts match. + +## Connector Label Placer Data Model + +- `ConnectorLabelPlacer` (static class) — `Place(IEnumerable, double)`. + +## Connector Label Placer Key Methods + +`IReadOnlyDictionary Place(IEnumerable lines, double +fontSize)` — returns a chosen label centre for every line that has a `MidpointLabel`. Lines without a +label, and lines with no waypoints, are omitted. The method estimates each label box from `fontSize`, +places the label at the longest clear segment midpoint, and nudges perpendicular to avoid overlap. + +## Connector Label Placer Design Constraints + +- A line without a `MidpointLabel` shall be omitted from the result. +- A label shall be placed at the midpoint of its line's longest segment unless doing so would overlap + an already-placed label, in which case it shall be moved to a shorter segment or nudged aside. +- Placement shall be deterministic for a given input order so the SVG and PNG renderers agree. + +## Connector Label Placer Interactions + +`ConnectorLabelPlacer` reads `LayoutLine.MidpointLabel` and `Waypoints` from the rendering model and +is called by the renderers (SVG and PNG systems) before drawing connector labels. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Abstractions-ConnectorLabelPlacer-OmitUnlabelled | `ConnectorLabelPlacer.Place` filtering unlabelled lines | +| Rendering-Abstractions-ConnectorLabelPlacer-LongestSegment | `ConnectorLabelPlacer.Place` segment choice | +| Rendering-Abstractions-ConnectorLabelPlacer-AvoidOverlap | `ConnectorLabelPlacer.Place` overlap avoidance | diff --git a/docs/design/rendering-abstractions/notation-metrics.md b/docs/design/rendering-abstractions/notation-metrics.md new file mode 100644 index 0000000..3133f52 --- /dev/null +++ b/docs/design/rendering-abstractions/notation-metrics.md @@ -0,0 +1,59 @@ +# Notation Metrics Unit Design + +Part of the Rendering Abstractions system. + +## Notation Metrics Overview + +The Notation Metrics unit is the single home for all intrinsic, theme-independent notation geometry +shared by the SVG and PNG renderers: end-marker (arrowhead) shapes and sizes, the port square, +folder-tab and note-fold proportions, rounded-rectangle corner scaling, badge fractions, and the +label-background inset. Every value is either a documented primitive constant or a documented +derivation of those primitives, so a geometry literal never appears more than once in the rendering +path. `MarkerVertex` expresses one vertex in tip-relative units. + +## Notation Metrics Data Model + +- `NotationMetrics` (static class) — the end-marker, port, folder, note, badge, and label constants + plus the `AlongLineLength`, `TriangleVertices`, `DiamondVertices`, and `RoundedRectRadius` helpers. +- `MarkerVertex` (readonly record struct) — `Along` (distance back from the tip) and `Across` + (perpendicular offset). + +## Notation Metrics Key Methods + +`double AlongLineLength(EndMarkerStyle style)` — returns the along-line length consumed by an +end-marker decoration; zero for `EndMarkerStyle.None`. + +`IReadOnlyList TriangleVertices()` — returns the three triangle vertices in tip-relative +units, shared by the open chevron, hollow triangle, and filled arrow markers. The apex overshoots the +line endpoint by `EndMarkerTipOvershoot`. + +`IReadOnlyList DiamondVertices()` — returns the four diamond vertices in tip-relative +units, shared by the hollow and filled diamonds. The far point lands exactly on the line endpoint. + +`double RoundedRectRadius(Theme theme)` — returns the theme corner radius scaled by +`RoundedRectCornerFactor`. + +## Notation Metrics Design Constraints + +- The canonical marker values shall be the historical SVG marker dimensions (triangle 10x7 refX 9, + diamond 14x8 refX 13, circle radius 4, bar 4x12); every renderer shall derive its markers from these + constants so the two renderers draw the identical shape. +- Each derived constant shall be documented as a derivation of a named primitive so no geometry literal + is duplicated. + +## Notation Metrics Interactions + +`NotationMetrics.RoundedRectRadius` reads `Theme.LineCornerRadius`. The end-marker helpers are called +by both renderers (SVG and PNG systems) and by the layout strategies (*Rendering Layout* system) that +reserve a clean approach using `AlongLineLength`. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Abstractions-NotationMetrics-TriangleGeometry | `NotationMetrics.TriangleVertices` and triangle constants | +| Rendering-Abstractions-NotationMetrics-DiamondGeometry | `NotationMetrics.DiamondVertices` and diamond constants | +| Rendering-Abstractions-NotationMetrics-CircleBarGeometry | `NotationMetrics` circle and bar constants | +| Rendering-Abstractions-NotationMetrics-Crossbar | `NotationMetrics.CrossbarX` | +| Rendering-Abstractions-NotationMetrics-AlongLineLength | `NotationMetrics.AlongLineLength` | +| Rendering-Abstractions-NotationMetrics-BoxDecorations | `NotationMetrics` decoration constants | diff --git a/docs/design/rendering-abstractions/registries.md b/docs/design/rendering-abstractions/registries.md new file mode 100644 index 0000000..9f77a5e --- /dev/null +++ b/docs/design/rendering-abstractions/registries.md @@ -0,0 +1,62 @@ +# Registries Unit Design + +Part of the Rendering Abstractions system. + +## Registries Overview + +The Registries unit provides two service-provider lookups. `LayoutAlgorithmRegistry` keys algorithms +by their `Id`; `RendererRegistry` keys renderers by their `MediaType` and by every advertised +`IRenderer.FileExtensions` value. Consumers register the implementations they wish to offer and +resolve one at run time by algorithm identifier, output media type, or output file extension. Neither +registry is thread-safe for concurrent registration. + +## Registries Data Model + +- `LayoutAlgorithmRegistry` (sealed class) — `Ids`, `Register`, `Contains`, `TryResolve`, `Resolve`. +- `RendererRegistry` (sealed class) — `MediaTypes`, `FileExtensions`, `Register`, `Contains`, + `ContainsExtension`, `TryResolve`, `TryResolveByExtension`, `Resolve`, and `ResolveByExtension`. + +## Registries Key Methods + +`LayoutAlgorithmRegistry Register(ILayoutAlgorithm algorithm)` — stores the algorithm keyed by its +`Id`, replacing any previous algorithm with the same identifier, and returns the registry for fluent +chaining. + +`ILayoutAlgorithm Resolve(string id)` — returns the algorithm registered under `id`, or throws +`KeyNotFoundException` when none is registered. `bool TryResolve(string, out ILayoutAlgorithm?)` +performs the same lookup without throwing. + +`RendererRegistry.Register` stores the renderer by `MediaType` and by every extension in +`IRenderer.FileExtensions`, replacing any previous renderer registered for the same media type or +extension. Media types and extensions are compared case-insensitively. + +`IRenderer ResolveByExtension(string extension)` — normalizes the supplied extension by trimming it, +adding an optional leading dot when needed, and lower-casing it for lookup. It returns the renderer +registered for that extension or throws `KeyNotFoundException`; `TryResolveByExtension` performs the +same lookup without throwing. + +## Registries Design Constraints + +- `Resolve` shall raise `KeyNotFoundException` when the requested identifier or media type is not + registered, so a configuration mistake surfaces immediately rather than as a later null-reference + failure. +- `Register` shall replace any existing entry with the same key, so a consumer can override a bundled + implementation. +- `RendererRegistry` shall index every advertised renderer extension, and extension lookup shall ignore + case and tolerate an omitted leading dot so file-driven callers can pass user-supplied suffixes. + +## Registries Interactions + +The registries hold `ILayoutAlgorithm` and `IRenderer` instances from the Rendering Contracts unit. A +caller resolves an algorithm using the identifier from `CoreOptions.Algorithm` and resolves a renderer +using either the desired output media type or an output filename extension such as `.svg`, `.png`, or +`webp`. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Abstractions-Registries-ResolveAlgorithm | `LayoutAlgorithmRegistry` identifier lookup | +| Rendering-Abstractions-Registries-ResolveRenderer | `RendererRegistry` media-type lookup | +| Rendering-Abstractions-Registries-ResolveRendererByExtension | `RendererRegistry` extension lookup | +| Rendering-Abstractions-Registries-MissingThrows | `Resolve` not-found exception | diff --git a/docs/design/rendering-abstractions/rendering-abstractions.md b/docs/design/rendering-abstractions/rendering-abstractions.md new file mode 100644 index 0000000..cafa4c9 --- /dev/null +++ b/docs/design/rendering-abstractions/rendering-abstractions.md @@ -0,0 +1,61 @@ +# Rendering Abstractions Design + +## Overview + +`DemaConsulting.Rendering.Abstractions` is the service provider interface (SPI) that sits between the +rendering model (*Rendering Model* system, `DemaConsulting.Rendering`) and the concrete layout and +renderer implementations. It defines the pluggable `ILayoutAlgorithm` and `IRenderer` contracts, the +registries that resolve an implementation by identifier, media type, or file extension, the visual +`Theme` model, and the single-source geometry helpers (`NotationMetrics`, `BoxMetrics`, +`ConnectorLabelPlacer`) that let every renderer draw identical decorations. The package depends only +on the rendering model and the .NET base class library. + +The ELK-inspired flow is: a `LayoutGraph` plus `LayoutOptions` is passed to an `ILayoutAlgorithm`, +which produces a placed `LayoutTree`; an `IRenderer` then draws that tree to an output stream. +Algorithms and renderers are selected at run time through the registries, so additional diagram +families and output formats are introduced purely additively. + +## Software Structure + +```text +DemaConsulting.Rendering.Abstractions (System) +├── RenderingContracts (Unit) +├── Registries (Unit) +├── Theme (Unit) +├── NotationMetrics (Unit) +├── BoxMetrics (Unit) +└── ConnectorLabelPlacer (Unit) +``` + +- **Rendering Contracts** — `ILayoutAlgorithm`, `IRenderer`, `RenderOptions`, `RenderOutput`. + Detailed in Rendering Contracts Unit Design. +- **Registries** — `LayoutAlgorithmRegistry`, `RendererRegistry`. Detailed in + Registries Unit Design. +- **Theme** — `Theme` and the built-in `Themes`. Detailed in Theme Unit Design. +- **Notation Metrics** — `NotationMetrics` and `MarkerVertex`. Detailed in + Notation Metrics Unit Design. +- **Box Metrics** — `BoxMetrics`. Detailed in Box Metrics Unit Design. +- **Connector Label Placer** — `ConnectorLabelPlacer`. Detailed in + Connector Label Placer Unit Design. + +## System Interactions + +A `LayoutGraph` plus `LayoutOptions` from the *Rendering Model* system is the input consumed by a +selected `ILayoutAlgorithm`; the algorithm produces a placed `LayoutTree`, which a selected +`IRenderer` draws to a concrete stream. The selected renderer receives `RenderOptions`, including a +`Theme`, and uses the shared notation, box, and connector-label geometry helpers to keep SVG and raster +outputs consistent. + +Callers resolve algorithms from `LayoutAlgorithmRegistry` by the configured algorithm identifier. +Callers resolve renderers from `RendererRegistry` by media type or by output file extension. Concrete +algorithm and renderer implementations live in the downstream Rendering.Layout, Rendering.Svg, and +Rendering.Skia systems; this system defines only the contracts, registries, and shared geometry those +implementations use. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Abstractions-Extensibility | The contracts and registries units | +| Rendering-Abstractions-Theming | The `Theme` and `Themes` types (see Theme Unit Design) | +| Rendering-Abstractions-SharedGeometry | The notation, box, and label geometry units | diff --git a/docs/design/rendering-abstractions/rendering-contracts.md b/docs/design/rendering-abstractions/rendering-contracts.md new file mode 100644 index 0000000..4a83447 --- /dev/null +++ b/docs/design/rendering-abstractions/rendering-contracts.md @@ -0,0 +1,42 @@ +# Rendering Contracts Unit Design + +Part of the Rendering Abstractions system. + +## Contracts Overview + +The Rendering Contracts unit defines the two extension-point interfaces and the value types that flow +across them. `ILayoutAlgorithm` is the high-level extension point that turns an unplaced graph into a +placed tree; `IRenderer` is the low-level extension point that turns a placed tree into an output +stream. `RenderOptions` carries the theme and sizing for a render, and `RenderOutput` bundles one +rendered stream with its metadata. + +## Contracts Data Model + +- `ILayoutAlgorithm` (interface) — `Id` and `Apply(LayoutGraph, LayoutOptions)`. +- `IRenderer` (interface) — `MediaType`, `DefaultExtension`, `FileExtensions`, and + `Render(LayoutTree, RenderOptions, Stream)`. +- `RenderOptions` (sealed record) — `Theme`, `Scale`, `Dpi`, `DepthLimit`. +- `RenderOutput` (sealed record) — `SuggestedFileName`, `MediaType`, `Data`, `Warnings`. + +## Contracts Design Constraints + +- An `ILayoutAlgorithm` shall expose a stable `Id` that matches the value read from + `CoreOptions.Algorithm`, and shall ignore options it does not understand so callers may pass options + intended for other algorithms without error. +- An `IRenderer` shall expose its media type, default extension, and every file extension it produces, + and shall write only to the caller-supplied `Stream` without filesystem access. +- Adding a new algorithm or renderer shall be an additive change: a new implementation of these + interfaces requires no change to the existing contracts. + +## Contracts Interactions + +`ILayoutAlgorithm.Apply` consumes a `LayoutGraph` and `LayoutOptions` from the rendering model and +produces a `LayoutTree`. `IRenderer.Render` consumes that `LayoutTree` and a `RenderOptions` (whose +`Theme` comes from the Theme unit). Instances are registered in and resolved from the Registries unit. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Abstractions-Contracts-Algorithm | `ILayoutAlgorithm.Id` and `ILayoutAlgorithm.Apply` | +| Rendering-Abstractions-Contracts-Renderer | `IRenderer` output contract members | diff --git a/docs/design/rendering-abstractions/theme.md b/docs/design/rendering-abstractions/theme.md new file mode 100644 index 0000000..00bddbd --- /dev/null +++ b/docs/design/rendering-abstractions/theme.md @@ -0,0 +1,43 @@ +# Theme Unit Design + +Part of the Rendering Abstractions system. + +## Theme Overview + +The Theme unit defines the visual parameters for rendering — depth-indexed fill colors, stroke color +and width, corner radius, font sizes, padding, and connector-approach geometry — and provides three +ready-made themes (`Light`, `Dark`, `Print`). Font choice is intentionally not part of the theme; each +renderer hardcodes its own typeface for consistent output. + +## Theme Data Model + +- `Theme` (sealed record) — `DepthFillColors`, `StrokeColor`, `StrokeWidth`, `LineCornerRadius`, + `FontSizeTitle`, `FontSizeBody`, `LabelPadding`, `ConnectorStub`, `BendRadius`, `CleanLegMargin`. +- `Themes` (static class) — the built-in `Light`, `Dark`, and `Print` themes. + +## Theme Key Methods + +`double ConnectorApproachZone(double connectorClearance)` — returns the clear distance a connector +needs off a box face before it can bend, computed as `ConnectorStub + BendRadius + connectorClearance`. + +`string BackgroundColor` — the depth-0 fill color, used to occlude a connector line behind a hollow +enclosing end marker. + +## Theme Design Constraints + +- `ConnectorApproachZone` shall equal the sum of the perpendicular stub, the corner bend radius, and + the caller-supplied clearance, so that space reserved by layout matches geometry drawn by renderers. +- The `Light` and `Dark` themes shall carry identical connector geometry; the `Print` theme shall use a + tighter stub and a zero bend radius suited to monochrome output. + +## Theme Interactions + +`Theme` is carried by `RenderOptions` (Rendering Contracts unit) and read by `NotationMetrics` and +`BoxMetrics` when deriving box and marker geometry. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Abstractions-Theme-ApproachZone | `Theme.ConnectorApproachZone` | +| Rendering-Abstractions-Theme-BuiltInGeometry | `Themes.Light`, `Themes.Dark`, and `Themes.Print` | diff --git a/docs/design/rendering-layout/connector-router.md b/docs/design/rendering-layout/connector-router.md new file mode 100644 index 0000000..d6445ac --- /dev/null +++ b/docs/design/rendering-layout/connector-router.md @@ -0,0 +1,77 @@ +# ConnectorRouter Unit Design + +Part of the Rendering Layout system. + +## ConnectorRouter Purpose + +`ConnectorRouter` is the public routing-orchestration entry point for connecting boxes that some +caller has already placed. Given the placed boxes and a list of connections, it produces one routed +`LayoutLine` per connection, choosing boundary anchors, assembling per-connection obstacle sets, and +delegating the actual path to the router selected by the `EdgeRouting` style. It complements +`LayeredLayoutAlgorithm`: where the algorithm places and routes a whole graph, `ConnectorRouter` +routes connectors among boxes whose positions are already fixed (for example a containment or +free-form placement produced outside the layered pipeline). + +## ConnectorRouter Data Model + +The unit comprises three public types plus the `EdgeRouting` option: + +- `Connection(From, To, TargetEnd, LineStyle, Label)` — an immutable record naming the source and + target `LayoutBox` (by instance identity) and the styling to carry onto the routed line. It holds + no geometry and no domain concept; anchors are derived from the boxes' placement at routing time. +- `ConnectorRouteOptions(EdgeRouting, Clearance)` — an immutable record selecting the routing style + (default `EdgeRouting.Orthogonal`) and the obstacle clearance in logical pixels (default `12.0`, + caller-overridable). +- `ConnectorRouter` — a stateless static class exposing a batch `Route(boxes, connections, options)` + and a single-connection `Route(boxes, connection, options)` convenience overload. + +`EdgeRouting` is the closed routing-style vocabulary defined in the `DemaConsulting.Rendering` model. +It mirrors ELK's `elk.edgeRouting` and today carries the single value `Orthogonal`. The style is also +exposed on the open property system as `CoreOptions.EdgeRouting` (id `rendering.edgerouting`, default +`Orthogonal`), so routing can be selected per scope alongside `CoreOptions.Algorithm`. + +## ConnectorRouter Methods + +`Route(boxes, connection, options)` rejects null arguments (including a null `From` or `To`) with +`ArgumentNullException`, then: + +1. **Anchor selection.** Computes each box centre and, for each endpoint, chooses the midpoint of the + box side whose outward normal best points at the opposing box centre (right/left when the + horizontal separation dominates, otherwise bottom/top). The chosen `PortSide` is retained so the + route exits and enters perpendicular to the face. +2. **Obstacle set.** Builds a `Rect` per box, excluding the connection's two endpoint boxes matched + by reference identity, so the connector is free to leave and enter the boxes it joins. +3. **Dispatch.** Routes through the router realizing `options.EdgeRouting`. Today `Orthogonal` maps to + the internal `OrthogonalEdgeRouter.RouteWithStatus`, which is the implementation behind the enum + value and remains internal to the Layout system. The dispatch is a single-arm switch structured so + new styles slot in additively. +4. **Assembly.** Wraps the returned waypoints in a `LayoutLine` carrying the connection's `TargetEnd`, + `LineStyle`, and `Label`, with `SourceEnd` left `None`. + +The batch overload applies the single-connection routine to each connection and returns one line per +connection in input order. + +## ConnectorRouter Error Handling + +Null `boxes`, `connections`, `connection`, `options`, or a connection's `From`/`To` throw +`ArgumentNullException`. An `EdgeRouting` value with no shipped router throws `NotSupportedException`; +this is unreachable today because `Orthogonal` is the only enum value, but guards future additions. + +## ConnectorRouter Interactions + +`ConnectorRouter` consumes the `LayoutBox`, `LayoutLine`, `Point2D`, `Rect`, `PortSide`, +`EndMarkerStyle`, `LineStyle`, and `EdgeRouting` model types and the internal `OrthogonalEdgeRouter` +engine. It produces `LayoutLine` nodes that a caller drops into a `LayoutTree` alongside the placed +`LayoutBox` nodes for a renderer to draw. It is independent of the layered pipeline and can be used on +any set of placed boxes. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-ConnectorRouter-AnchorsFaceEachOther | ConnectorRouter behavior described above | +| Rendering-Layout-ConnectorRouter-AvoidsObstacles | ConnectorRouter behavior described above | +| Rendering-Layout-ConnectorRouter-ExcludesEndpoints | ConnectorRouter behavior described above | +| Rendering-Layout-ConnectorRouter-CarriesStyling | ConnectorRouter behavior described above | +| Rendering-Layout-ConnectorRouter-BatchOrder | ConnectorRouter behavior described above | +| Rendering-Layout-ConnectorRouter-Validation | ConnectorRouter behavior described above | diff --git a/docs/design/rendering-layout/containment-layout-algorithm.md b/docs/design/rendering-layout/containment-layout-algorithm.md new file mode 100644 index 0000000..0f9b8c4 --- /dev/null +++ b/docs/design/rendering-layout/containment-layout-algorithm.md @@ -0,0 +1,76 @@ +# ContainmentLayoutAlgorithm Unit Design + +Part of the Rendering Layout system. + +## ContainmentLayoutAlgorithm Purpose + +`ContainmentLayoutAlgorithm` is a second public `ILayoutAlgorithm` implementation alongside +`LayeredLayoutAlgorithm`. Where the layered algorithm arranges nodes by their connectivity into +Sugiyama layers, the containment algorithm arranges them by their reading order: it packs the graph's +top-level nodes into rows within a heuristic width budget and then routes each edge around the packed +boxes. It composes the two public building blocks of the system — the `ContainmentLayout` packer and +the `ConnectorRouter` orchestration — rather than the layered pipeline, and suits views whose elements +group as peers inside a container rather than flowing along a directed spine. It is additive: adding it +changes no existing output and leaves the layered algorithm untouched. + +## ContainmentLayoutAlgorithm Data Model + +The class is stateless and sealed. It exposes the `AlgorithmId` constant (`"containment"`) and returns +it from the `Id` property, the stable identifier under which the algorithm is selected and registered. +Two private constants govern the arrangement: `CanvasAspectRatio` (`4/3`) biases the derived content +width toward a landscape block, and `NodeSpacing` (`24.0` logical pixels) is the inter-box gap, sized +wider than the router's approach stub so a connector can pass cleanly between two packed boxes. Its +single behavior is `Apply(LayoutGraph graph, LayoutOptions options)`, which returns a `LayoutTree` +carrying the packed region size and a flat list of `LayoutNode` items (`LayoutBox` per top-level node +followed by `LayoutLine` per routed edge). + +## ContainmentLayoutAlgorithm Methods + +`Apply(graph, options)` rejects null `graph` or `options` with `ArgumentNullException`, then: + +1. **Leaf-box conversion.** Converts each top-level `LayoutGraphNode` into a leaf `LayoutBox` at the + origin (carrying the node's width, height, and label), recording each node's positional index. A + node's nested `Children` are treated as opaque and are not laid out at this level (that is the + recursive hierarchical engine's responsibility, not this flat algorithm's). +2. **Content-width heuristic.** Derives the packer's `MaxContentWidth` from the boxes: the square root + 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. +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, + mirroring the layered algorithm's handling of out-of-graph endpoints. +5. **Routing.** Routes the connections around the packed boxes via `ConnectorRouter.Route`, selecting + the routing style from `CoreOptions.EdgeRouting` on the supplied options (default `Orthogonal`). +6. **Assembly.** Returns a `LayoutTree` with the region `Width`/`Height` and the packed boxes followed + by the routed lines. + +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. + +## ContainmentLayoutAlgorithm Error Handling + +Null `graph` or `options` throw `ArgumentNullException`. Edges with an out-of-graph endpoint are +skipped rather than treated as errors. All other behavior is inherited from the composed +`ContainmentLayout` and `ConnectorRouter` units. + +## ContainmentLayoutAlgorithm Interactions + +`ContainmentLayoutAlgorithm` depends on the `ILayoutAlgorithm`, `LayoutGraph`, `LayoutTree`, +`CoreOptions`, and related model types, and composes the public `ContainmentLayout` and +`ConnectorRouter` units of this same system. It is resolvable by renderers and callers through the +layout registry under the `"containment"` identifier, selected via `CoreOptions.Algorithm`. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-ContainmentAlgorithm-Identity | ContainmentLayoutAlgorithm behavior described above | +| Rendering-Layout-ContainmentAlgorithm-PacksNodes | ContainmentLayoutAlgorithm behavior described above | +| Rendering-Layout-ContainmentAlgorithm-RoutesEdges | ContainmentLayoutAlgorithm behavior described above | +| Rendering-Layout-ContainmentAlgorithm-RoutesAroundObstacle | ContainmentLayoutAlgorithm behavior described above | +| Rendering-Layout-ContainmentAlgorithm-EmptyGraph | ContainmentLayoutAlgorithm behavior described above | +| Rendering-Layout-ContainmentAlgorithm-SkipsOutOfGraphEdges | ContainmentLayoutAlgorithm behavior described above | +| Rendering-Layout-ContainmentAlgorithm-Validation | ContainmentLayoutAlgorithm behavior described above | diff --git a/docs/design/rendering-layout/containment-layout.md b/docs/design/rendering-layout/containment-layout.md new file mode 100644 index 0000000..80439d5 --- /dev/null +++ b/docs/design/rendering-layout/containment-layout.md @@ -0,0 +1,75 @@ +# ContainmentLayout Unit Design + +Part of the Rendering Layout system. + +## ContainmentLayout Purpose + +`ContainmentLayout` is the public, model-speaking containment building block: it packs a set of +already-sized `LayoutBox` children into a single container region, arranging them into rows within a +width budget. It complements `ConnectorRouter` and `LayeredLayoutAlgorithm`: where the algorithm places +and routes a whole connected graph and `ConnectorRouter` joins already-placed boxes, `ContainmentLayout` +arranges peer boxes inside a container when their reading order, not their connectivity, drives the +layout (for example the members of a package or the contents of a folder). It is the single-level packing +primitive; multi-level folder/canvas assembly is composed from it by higher layers rather than provided +here. + +## ContainmentLayout Data Model + +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. +- `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. +- `ContainmentLayout` — a stateless static class exposing `Pack(children, options)`. + +## ContainmentLayout Methods + +`Pack(children, options)` rejects null arguments — a null `children` list, null `options`, or any null +child element — with `ArgumentNullException`, then: + +1. **Size mapping.** Maps each child onto a size-only `PackItem` built from its `Width` and `Height`, + correlating the packer's output back to each child by index. +2. **Packing.** Calls the internal `ContainmentPacker.Pack` with the mapped items and the options' + content width, gaps, and padding to obtain the packed rectangles and region size. +3. **Repositioning.** Produces one new `LayoutBox` per child via `with { X = ..., Y = ... }`, updating + only the coordinates from the corresponding packed rectangle and carrying every other field (label, + depth, shape, compartments, nested children, keyword) through unchanged. +4. **Assembly.** Returns a `ContainmentResult` with the region `Width`/`Height` and the repositioned + children in input order. + +The operation is deterministic and order-preserving, never overlaps two children, keeps every child +within the reported region (which includes the outer padding on every side), places a child wider than +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. + +## ContainmentLayout Error Handling + +Null `children`, `options`, or a null child element throw `ArgumentNullException`. Packing behavior for +degenerate sizes (zero or negative dimensions) follows the underlying `ContainmentPacker`; the public +operation adds no further validation beyond null rejection. + +## ContainmentLayout Interactions + +`ContainmentLayout` consumes the `LayoutBox` model type and the internal `ContainmentPacker`, +`PackItem`, `PackedRect`, and `PackResult` engine types, which remain internal to the Layout system — +the public API speaks only `LayoutBox`. It produces `LayoutBox` children that a caller nests inside a +container box (offsetting by the container's placement) and drops into a `LayoutTree` for a renderer to +draw. It is independent of the layered pipeline and can be used on any set of sized boxes. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-ContainmentLayout-Order | ContainmentLayout behavior described above | +| Rendering-Layout-ContainmentLayout-NoOverlap | ContainmentLayout behavior described above | +| Rendering-Layout-ContainmentLayout-WithinRegion | ContainmentLayout behavior described above | +| Rendering-Layout-ContainmentLayout-Wrapping | ContainmentLayout behavior described above | +| Rendering-Layout-ContainmentLayout-OversizedChild | ContainmentLayout behavior described above | +| 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-Validation | ContainmentLayout behavior described above | diff --git a/docs/design/rendering-layout/default-layout.md b/docs/design/rendering-layout/default-layout.md new file mode 100644 index 0000000..a3d8904 --- /dev/null +++ b/docs/design/rendering-layout/default-layout.md @@ -0,0 +1,80 @@ +# DefaultLayout Unit Design + +Part of the Rendering Layout system. + +## DefaultLayout Purpose + +`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 +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 +existing algorithms and change no existing behavior. + +## DefaultLayout Data Model + +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 +`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. + +## DefaultLayout Methods + +`LayoutEngine.Layout(graph, options)` resolves against the shared default registry; +`LayoutEngine.Layout(graph, options, registry)` resolves against a caller-supplied registry. Both reject +null arguments with `ArgumentNullException`, then: + +1. **Resolve the algorithm identifier.** The identifier is read from an explicit `CoreOptions.Algorithm` + on the graph, else from an explicit `CoreOptions.Algorithm` on the options, else `DefaultAlgorithmId` + (`"hierarchical"`). Resolution consults *explicit* settings only (via `TryGet`), so an unset graph and + options fall through to the hierarchical default rather than the `CoreOptions.Algorithm` property + default of `"layered"`. The graph takes precedence over the options because, in the ELK-style model, + layout options are naturally attached to the graph being laid out. +2. **Resolve and apply.** The identifier is resolved from the registry and the resolved algorithm's + `Apply(graph, options)` produces the placed `LayoutTree`. + +Defaulting to the hierarchical engine is what lets the single facade serve both flat and nested graphs. +It is safe because of the hierarchical engine's flat-graph equivalence guarantee: for a graph with no +container nodes the engine returns output byte-for-byte identical to the selected leaf algorithm +(default `"layered"`) applied directly. A flat graph therefore lays out exactly as the layered algorithm +would, while a nested graph is composed correctly — with no decision required from the caller. + +## DefaultLayout Design Constraints + +- The factory shall live in the Layout package, not in Abstractions, because it references the concrete + bundled algorithms; the `LayoutAlgorithmRegistry` it populates remains in Abstractions. This keeps the + dependency direction intact (model <- Abstractions <- Layout). +- The facade shall default to the hierarchical engine, not the layered algorithm, so one entry point + handles both flat and nested graphs; the flat-graph equivalence guarantee makes this behavior- + preserving. +- The facade shall consult only explicit algorithm declarations when resolving, so an unset graph and + options reach the hierarchical default rather than the layered property default. + +## DefaultLayout Error Handling + +Null `graph`, `options`, or (three-argument overload) `registry` throw `ArgumentNullException`. A +declared algorithm identifier absent from the resolving registry surfaces the registry's +`KeyNotFoundException`. + +## DefaultLayout Interactions + +`LayoutAlgorithms` depends on `LayoutAlgorithmRegistry` and the three 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. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-DefaultRegistry-BundledAlgorithms | DefaultLayout behavior described above | +| Rendering-Layout-LayoutEngine-DefaultAlgorithm | DefaultLayout behavior described above | +| Rendering-Layout-LayoutEngine-Resolution | DefaultLayout behavior described above | +| Rendering-Layout-LayoutEngine-FlatEquivalence | DefaultLayout behavior described above | +| Rendering-Layout-LayoutEngine-NestedComposition | DefaultLayout behavior described above | +| Rendering-Layout-LayoutEngine-CustomRegistry | DefaultLayout behavior described above | +| Rendering-Layout-LayoutEngine-Validation | DefaultLayout behavior described above | diff --git a/docs/design/rendering-layout/edge-routing-option.md b/docs/design/rendering-layout/edge-routing-option.md new file mode 100644 index 0000000..688195f --- /dev/null +++ b/docs/design/rendering-layout/edge-routing-option.md @@ -0,0 +1,41 @@ +# EdgeRouting Option Unit Design + +Part of the Rendering Layout system. + +## EdgeRouting Option Overview + +The EdgeRouting option is the Layout-owned routing-selection behavior used when connectors are routed +among already-placed boxes. The closed `EdgeRouting` enum and the `CoreOptions.EdgeRouting` property key +are declared in the Rendering model so the open option system can carry the value at graph, node, edge, +or standalone option scope. Rendering.Layout realizes the behavior by reading that option and dispatching +`ConnectorRouter` to the corresponding routing implementation. + +## EdgeRouting Option Data Model + +- `CoreOptions.EdgeRouting` — the open property key with id `rendering.edgerouting`, defaulting to + `EdgeRouting.Orthogonal`. +- `EdgeRouting.Orthogonal` — the shipped routing style value. The enum type is defined by the Rendering + model, not by the Layout project. +- `ConnectorRouteOptions(EdgeRouting, Clearance)` — the Layout-side route options record consumed by + `ConnectorRouter`, defaulting to orthogonal routing and twelve logical pixels of clearance. + +## EdgeRouting Option Behavior + +A caller can set `CoreOptions.EdgeRouting` on any property holder and read the selected routing style +back through the same open option system. An unset scope returns the declared orthogonal default. +`ConnectorRouter` consumes the effective `EdgeRouting` value from `ConnectorRouteOptions`; the current +single shipped value dispatches to the internal orthogonal router. The switch is structured for additive +future routing styles while preserving the current default behavior. + +## EdgeRouting Option Scope Note + +The enum declaration lives in the Rendering model project because `CoreOptions` belongs to the shared +configuration vocabulary. This Layout unit therefore owns the behavior of consuming the option for +routing, not the model file that declares the enum. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-EdgeRouting-Selection | Option key/defaults and Layout-side routing consumption | +| Rendering-Layout-EdgeRouting-Defaults | Option key/defaults and Layout-side routing consumption | diff --git a/docs/design/rendering-layout/engine/containment-packer.md b/docs/design/rendering-layout/engine/containment-packer.md new file mode 100644 index 0000000..0e5a052 --- /dev/null +++ b/docs/design/rendering-layout/engine/containment-packer.md @@ -0,0 +1,64 @@ +# ContainmentPacker Unit Design + +Part of the Rendering Layout system. + +## ContainmentPacker Purpose + +`ContainmentPacker` arranges a sequence of variable-size items into rows within a width budget. It +places items left to right, wraps to a new row when the next item would exceed the maximum content +width, and sizes the enclosing region to fit all items plus uniform outer padding. It is used to +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)`. + +## ContainmentPacker Methods + +`Pack(items, maxContentWidth, horizontalGap, verticalGap, padding)` 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. +2. **Row filling.** A horizontal cursor starts at the left padding offset. Each item is placed at + the current cursor and the cursor advances past the item plus `horizontalGap`. The row height + tracks the tallest item placed so far. +3. **Wrapping.** Before placing an item that is not first in its row, the packer checks whether its + 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 + is the last row's bottom plus padding. + +Input order is preserved, and the left-to-right, no-backtracking placement is what guarantees that +no two rectangles overlap and that every rectangle stays within the reported region. + +## ContainmentPacker Error Handling + +A null `items` argument throws `ArgumentNullException`. An empty item list returns a padding-only +region. No other input causes a throw; an oversized item is handled by the first-in-row exemption +rather than by an error. + +## 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. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-ContainmentPacker-SingleRow | ContainmentPacker behavior described above | +| Rendering-Layout-ContainmentPacker-Wrapping | ContainmentPacker behavior described above | +| Rendering-Layout-ContainmentPacker-NoOverlap | ContainmentPacker behavior described above | +| Rendering-Layout-ContainmentPacker-WithinBounds | ContainmentPacker behavior described above | +| Rendering-Layout-ContainmentPacker-OversizedItem | ContainmentPacker behavior described above | +| Rendering-Layout-ContainmentPacker-EmptyInput | ContainmentPacker behavior described above | +| Rendering-Layout-ContainmentPacker-SingleItem | ContainmentPacker behavior described above | diff --git a/docs/design/rendering-layout/engine/engine.md b/docs/design/rendering-layout/engine/engine.md new file mode 100644 index 0000000..3601a9c --- /dev/null +++ b/docs/design/rendering-layout/engine/engine.md @@ -0,0 +1,40 @@ +# Engine Subsystem Design + +Part of the Rendering Layout system. + +## Engine Subsystem Overview + +The Engine subsystem holds the reusable geometric components. Each is independent of any semantic model +and operates purely on sizes, edges, anchors, and rectangles. The `Rect` value type they consume is the +public axis-aligned rectangle in logical pixels defined by the `DemaConsulting.Rendering` model and +returned by the placement engines. + +## Engine Units + +```text +Engine (Subsystem) +├── OrthogonalEdgeRouter (Unit) +├── ContainmentPacker (Unit) +├── InterconnectionLayoutEngine (Unit) +└── LayeredPipeline (Unit) +``` + +- **OrthogonalEdgeRouter** — routes individual orthogonal connectors. Detailed in + OrthogonalEdgeRouter Unit Design. +- **ContainmentPacker** — packs variable-size items into rows. Detailed in + ContainmentPacker Unit Design. +- **InterconnectionLayoutEngine** — adapts the layered pipeline to the geometric placement result. + Detailed in InterconnectionLayoutEngine Unit Design. +- **LayeredPipeline** — the whole `Engine/Layered` staged layout pipeline. Detailed in + Layered Pipeline Unit Design. + +This subsystem design intentionally does not restate unit internals; those details live in the unit +design documents listed above. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-OrthogonalRouting | OrthogonalEdgeRouter unit design | +| Rendering-Layout-Containment | ContainmentPacker unit design | +| Rendering-Layout-Interconnection | InterconnectionLayoutEngine and LayeredPipeline unit designs | diff --git a/docs/design/rendering-layout/engine/interconnection-layout-engine.md b/docs/design/rendering-layout/engine/interconnection-layout-engine.md new file mode 100644 index 0000000..3d61a45 --- /dev/null +++ b/docs/design/rendering-layout/engine/interconnection-layout-engine.md @@ -0,0 +1,54 @@ +# InterconnectionLayoutEngine Unit Design + +Part of the Rendering Layout system. + +## InterconnectionLayoutEngine Purpose + +`InterconnectionLayoutEngine` places directed graphs and routes all connector lines using a full +Sugiyama-style pipeline. It is a thin facade that assembles and runs the reusable +`LayeredLayoutPipeline` (see *Layered Pipeline*) with its default stage sequence, the Right layout +direction, and flat hierarchy handling. Its `Place` API and `LayerResult` output are the stable +internal contract; the facade produces byte-for-byte identical geometry to the previous monolithic +implementation, proven by an equivalence test against a legacy oracle. + +## InterconnectionLayoutEngine Data Model + +`InterconnectionLayoutEngine` is a static class with no instance state. Input is an +`IReadOnlyList` (width and height per node) and an `IReadOnlyList` (directed +edges by index). The result is a `LayerResult` record carrying one `Rect` per real node in input +order, the bounding-box totals, a `NodeLayers` list of longest-path layer indices, a +`ConnectorWaypoints` list of orthogonal waypoints, and the `AcyclicEdges` set that is index-aligned +with `ConnectorWaypoints`. + +## InterconnectionLayoutEngine Methods + +`Place(nodes, edges)` builds a `LayeredGraph` from the inputs, assembles a `LayeredLayoutPipeline` +with the default stages, and runs it. It then reads the placed coordinates, column extents, layer +assignments, and waypoints from the graph state and assembles the `LayerResult`. Because the +pipeline drops self-loops, de-duplicates identical directed pairs, and reverses back edges, +`ConnectorWaypoints` holds one polyline per acyclic edge; consumers key a `(source, target)` lookup +on `AcyclicEdges` (reversing the polyline for a reversed back edge) to recover each input edge's +route. + +## InterconnectionLayoutEngine Error Handling + +Null `nodes` or `edges` arguments throw `ArgumentNullException`. An empty `nodes` list returns a +minimal-size `LayerResult` with empty lists without performing any computation. Out-of-range edge +indices and self-loops are ignored by the pipeline stages. + +## InterconnectionLayoutEngine Interactions + +`InterconnectionLayoutEngine` depends on `LayeredLayoutPipeline` and `LayeredGraph` (the staged +pipeline it assembles and runs), the `Rect` value type, and the `Point2D` point type used for +waypoints. It is called by the public `LayeredLayoutAlgorithm` and by the interconnection view +strategy to obtain a placement result. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-InterconnectionEngine-Layering | InterconnectionLayoutEngine behavior described above | +| Rendering-Layout-InterconnectionEngine-NonOverlapping | InterconnectionLayoutEngine behavior described above | +| Rendering-Layout-InterconnectionEngine-DummyNodes | InterconnectionLayoutEngine behavior described above | +| Rendering-Layout-InterconnectionEngine-Waypoints | InterconnectionLayoutEngine behavior described above | +| Rendering-Layout-InterconnectionEngine-Deterministic | InterconnectionLayoutEngine behavior described above | diff --git a/docs/design/rendering-layout/engine/layered-pipeline.md b/docs/design/rendering-layout/engine/layered-pipeline.md new file mode 100644 index 0000000..52c9db2 --- /dev/null +++ b/docs/design/rendering-layout/engine/layered-pipeline.md @@ -0,0 +1,104 @@ +# Layered Pipeline Unit Design + +Part of the Rendering Layout system. + +## Layered Pipeline Overview + +The layered pipeline is a reusable, composable layered-layout engine that reproduces ELK's layered +(Sugiyama-style) algorithm. It replaces a single monolithic placement method with an ordered +sequence of small, single-purpose stages. Each stage reads the state produced by earlier stages and +writes the state it owns, so the pipeline can be extended, reordered, and unit-tested one stage at a +time. It was produced by a behavior-preserving extraction: for every input it produces exactly the +same rectangles, totals, layer assignments, and connector waypoints as the previous implementation, +verified byte for byte by the pipeline-equivalence tests. + +## Layered Pipeline Data Model + +`LayeredGraph` is the mutable shared state threaded through every stage. Construction takes the input +`LayerNode` list, the `LayerEdge` list, and a `LayoutDirection`, rejecting null nodes or edges with +`ArgumentNullException`. As the stages run, it accumulates the acyclic edge set and its reversal +flags, per-node layer indices, the augmented node and sub-edge lists (real boxes plus long-edge +dummies), the per-layer node groups, the augmented-node coordinate arrays, the column extents, the +per-sub-edge port positions, the per-sub-edge bend points, and finally the assembled per-edge +waypoints. The `BackEdgeEntryApproach` parameter (default `ConnectorClearance`) lets a +decoration-aware caller lengthen a reversed edge's final approach without disturbing default +geometry. The `SwapNodeAxes` seam swaps each node's width and height for the down/up directions so +the direction-agnostic stages space layers by the correct extent. + +Each stage implements `ILayoutStage` (`void Apply(LayeredGraph graph)`) and mutates the graph in +place. Stages are stateless and may be shared across pipelines. `LayeredLayoutMetrics` holds the +shared spacing, clearance, and padding constants — intentionally identical to the constants of the +previous monolithic engine so the pipeline reproduces its output exactly. The `LayoutDirection` enum +selects Right, Down, Left, or Up flow; `HierarchyHandling` selects Flat (supported) or Recursive +(reserved). + +## Layered Pipeline Assembly + +A pipeline is assembled through the fluent `LayeredLayoutPipeline.PipelineBuilder` returned by +`Builder()`. The builder exposes `Direction`, `Hierarchy`, `AddStage`, `AddDefaultStages`, and +`Build`. `AddStage` rejects a null stage with `ArgumentNullException`, and `Build` fails fast with +`NotSupportedException` when recursive hierarchy handling is requested. `Run(graph)` first calls +`AxisTransform.NormalizeInputAxes` to normalize the input node axes for the requested direction, then +applies every stage in order. `Run` rejects a null graph with `ArgumentNullException`. + +## Layered Pipeline Stages + +The default stage sequence added by `AddDefaultStages` runs in this order: + +1. **CycleBreaker.** Detects cycle-causing edges by depth-first back-edge detection and reverses + them to produce an acyclic edge set, recording which retained edges were reversed. Self-loops and + duplicate directed edges are dropped. +2. **LayerAssigner.** Assigns each node the longest-path layer index, so every edge runs from a + strictly lower layer to a strictly higher layer and no same-layer connection is possible. +3. **LongEdgeSplitter.** For each edge spanning more than one layer, inserts one zero-size dummy node + per intermediate layer so the connector routes through inter-layer corridors rather than through + intervening boxes. A span-one edge gains no dummy node. +4. **CrossingMinimizer.** Orders the augmented nodes within each layer, using alternating sweep + passes, to reduce edge crossings while keeping every augmented node in its layer group. +5. **BrandesKopfPlacer.** Assigns every augmented node absolute coordinates, with layer columns + ordered left to right and symmetric forks centered between their targets. +6. **PortDistributor.** Distributes connector ports along each box face and assigns each sub-edge a + source-side and target-side port that lies within the corresponding node face. +7. **LayeredCorridorRouter.** Assigns routing slots per corridor and emits orthogonal bend points, adding + no bend points for a straight sub-edge. It reads `BackEdgeEntryApproach` to reserve a minimum + final approach for a reversed edge; at the default this clamp is a no-op, keeping forward geometry + byte-identical. +8. **LongEdgeJoiner.** Concatenates the bend points of a split edge's sub-edges into one polyline per + original edge. +9. **AxisTransform.** Maps the abstract left-to-right along/cross coordinates onto screen coordinates + 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. + +## Layered Pipeline Interactions + +All types are internal and consume only the geometric value types of the Layout system (`Point2D`, +`Rect`) plus the internal `LayerNode`, `LayerEdge`, `AugNode`, and `AugEdge` records; no stage +references any semantic model. The pipeline is assembled and run by `InterconnectionLayoutEngine` +and, transitively, by the public `LayeredLayoutAlgorithm`. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-LayeredPipeline-StagedPipeline | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-BehaviorPreserving | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-FlatHierarchyOnly | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-Directions | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-OrthogonalConnectors | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-CycleBreaking | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-LayerAssignment | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-LongEdgeSplitting | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-CrossingMinimization | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-CoordinateAssignment | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-PortDistribution | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-OrthogonalRouting | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-BackEdgeApproach | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-LongEdgeJoining | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-ComponentPacking | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-SharedState | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-InputValidation | Layered pipeline 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 new file mode 100644 index 0000000..22bae0f --- /dev/null +++ b/docs/design/rendering-layout/engine/orthogonal-edge-router.md @@ -0,0 +1,64 @@ +# OrthogonalEdgeRouter Unit Design + +Part of the Rendering Layout system. + +## OrthogonalEdgeRouter Purpose + +`OrthogonalEdgeRouter` routes a single orthogonal connector between a source anchor and a target anchor, +steering around obstacle rectangles and keeping a requested clearance. It is the engine through +which all single-connector routing quality flows. + +## OrthogonalEdgeRouter Data Model + +`OrthogonalEdgeRouter` is a static class with no instance state. Inputs are the source and target `Point2D` +anchors, a list of obstacle `Rect`, a clearance distance, optional source and target `PortSide` +values, and an optional list of `CostBand` records. The result is a `RouteResult` record carrying +the ordered `Waypoints` and a `Crossed` flag. + +## OrthogonalEdgeRouter Methods + +`RouteWithStatus(source, target, obstacles, clearance, sourceSide?, targetSide?, costBands?)` +computes the route and reports whether it had to cross an obstacle. The algorithm is: + +1. **Perpendicular stubs.** When a side is supplied, the anchor is stepped off its edge by a short + stub so the connector leaves and enters boxes at right angles. Each stub length is capped to half + the gap to the opposing anchor so two facing stubs across a narrow gap meet at the midline + instead of overshooting. +2. **Grid construction.** Candidate grid lines are built from the two endpoint coordinates plus each + obstacle's near and far edges offset outward by the current clearance. +3. **Clearance-retry ladder.** An A\*-style search runs over the grid at successively smaller + clearances — full, half, quarter, then zero. Segments passing within the current clearance of an + obstacle are rejected; the largest clearance yielding an obstacle-free path is used. +4. **Crossing fallback.** Only when no obstacle-free path exists at any clearance (for example an + enclosed target) does the router fall back to a best-effort L-shape and set `Crossed = true`. +5. **Finalize.** The original anchors are re-attached outside their stubs and the path is + simplified — collinear interior points are removed while U-turns are preserved so a perpendicular + stub is never collapsed. + +A turn penalty biases the search toward routes with fewer bends. When cost bands are supplied, each +segment's length is scaled by the cheapest band covering its midpoint, so a discounted highway band +attracts wires into shared corridors while a null band list leaves cost neutral. The thin `Route` +wrapper returns only the `Waypoints` for callers that do not need the crossing status. + +## OrthogonalEdgeRouter Error Handling + +Null `source`, `target`, or `obstacles` arguments throw `ArgumentNullException`. Degenerate geometry +never throws: when no clean route exists the router returns a crossing route with `Crossed = true` +rather than failing, leaving the decision to surface a warning to the caller. + +## OrthogonalEdgeRouter Interactions + +`OrthogonalEdgeRouter` depends only on the `Point2D` and `Rect` geometric value types and the `PortSide` +enumeration for perpendicular-stub direction. It is a leaf engine invoked directly by callers that +route individual connectors; the `Crossed` flag feeds their layout-warning handling. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-OrthogonalEdgeRouter-Orthogonal | OrthogonalEdgeRouter behavior described above | +| Rendering-Layout-OrthogonalEdgeRouter-AvoidObstacles | OrthogonalEdgeRouter behavior described above | +| Rendering-Layout-OrthogonalEdgeRouter-Clearance | OrthogonalEdgeRouter behavior described above | +| Rendering-Layout-OrthogonalEdgeRouter-PerpendicularEnds | OrthogonalEdgeRouter behavior described above | +| Rendering-Layout-OrthogonalEdgeRouter-CrossingStatus | OrthogonalEdgeRouter behavior described above | +| Rendering-Layout-OrthogonalEdgeRouter-CostBands | OrthogonalEdgeRouter behavior described above | diff --git a/docs/design/rendering-layout/hierarchical-layout-algorithm.md b/docs/design/rendering-layout/hierarchical-layout-algorithm.md new file mode 100644 index 0000000..6fe322d --- /dev/null +++ b/docs/design/rendering-layout/hierarchical-layout-algorithm.md @@ -0,0 +1,103 @@ +# HierarchicalLayoutAlgorithm Unit Design + +Part of the Rendering Layout system. + +## HierarchicalLayoutAlgorithm Purpose + +`HierarchicalLayoutAlgorithm` is a third public `ILayoutAlgorithm` implementation: the recursive +hierarchical layout engine, analogous to ELK's `RecursiveGraphLayoutEngine`. Where the layered and +containment algorithms place a single flat scope, this engine lays out a *compound* graph — a graph +whose nodes may be containers of nested subgraphs — by recursively placing each container's children +and composing the sub-layouts into one absolute `LayoutTree`. It does not place boxes itself; it +selects a bundled *leaf* algorithm per scope and delegates the actual placement to it, then sizes each +container and composes the results. It is additive: it changes no existing output and is honored only +when a caller selects it by name. + +## HierarchicalLayoutAlgorithm Data Model + +The class is sealed and stateless with respect to any single layout. It exposes the `AlgorithmId` +constant (`"hierarchical"`) and returns it from `Id`. Two private constants govern container framing: +`ContainerPadding` (`12.0` logical pixels) is the inset kept on every side between a container border +and its children's sub-layout, and `ContainerTitleHeight` (`24.0` logical pixels) is the title band +reserved above the children of a container that carries a `Label` (a container with no label reserves +no band). The engine holds a single field, a `LayoutAlgorithmRegistry` used to resolve the per-scope +leaf algorithm by identifier. A default constructor builds a default registry containing the +bundled `LayeredLayoutAlgorithm` and `ContainmentLayoutAlgorithm`; an injecting constructor accepts a +caller-supplied registry (rejecting null). The engine treats a scope that explicitly selects its own +`"hierarchical"` identifier as selecting the default leaf algorithm, so recursion always terminates in a +bundled leaf algorithm. + +## HierarchicalLayoutAlgorithm Methods + +`Apply(graph, options)` rejects null `graph` or `options` with `ArgumentNullException`, resolves the +root scope's algorithm (the graph's explicit `CoreOptions.Algorithm` override if present, otherwise the +options' algorithm — default `"layered"`), and calls the recursive `LayoutScope`. `LayoutScope(graph, +algoId, options)` performs: + +1. **Flat fast path (equivalence guarantee).** If no direct node of the scope is a container + (`HasChildren` is false for all), the engine delegates straight to the resolved leaf algorithm and + returns its `LayoutTree` unchanged — no cloning, no post-processing, no mutation. This guarantees a + flat graph is placed byte-for-byte identically to invoking the leaf algorithm directly. +2. **Post-order recursion.** For each container child, the engine resolves the child's algorithm (the + node's `CoreOptions.Algorithm` override, else the inherited scope algorithm), recursively lays out + the child's subgraph, and records both the sub-layout and the container's effective size — the + sub-layout size grown by `ContainerPadding` on every side plus a title band when the container is + labelled. +3. **Sized view.** The engine builds an internal, side-effect-free *view* graph with the same nodes in + the same order (container nodes carrying their effective size, leaves their own size, labels copied) + and only the edges whose endpoints are both direct members of this scope. The caller's input graph + is never mutated. +4. **Placement.** The resolved leaf algorithm places the sized view, emitting one box per node in input + order followed by routed lines for the in-scope edges. +5. **Composition.** Each container's placed box receives its recursively laid-out children, translated + from their local origin to the box's padded (and title-offset) interior via a recursive `Translate` + that shifts nested boxes and line waypoints (local-to-absolute translation, following the + `ComponentPacker` precedent). +6. **Cross-container (LCA) routing.** Edges whose endpoints resolve to different direct-member + containers of this scope — mapped from any descendant endpoint up to its owning top-level box — are + routed at this level with `ConnectorRouter.Route`, steering around the sibling boxes; the + `EdgeRouting` style is read from the options. Edges already routed by the leaf algorithm (both + endpoints direct) or belonging to a lower scope (both endpoints under one container) are skipped. +7. **Assembly.** The engine returns a `LayoutTree` with the leaf algorithm's canvas size for this level + and the composed boxes followed by the leaf-routed lines and the cross-container lines. + +## HierarchicalLayoutAlgorithm Design Constraints + +- The flat-graph equivalence guarantee is load-bearing: the fast path must delegate directly to the + leaf algorithm and must not clone the graph or transform the tree, so selecting the engine never + changes existing output. +- The engine shall not mutate the caller's graph; re-sizing is expressed through the internal sized + view rather than by altering node sizes in place. +- Hierarchy handling is `HierarchyHandling.SeparateChildren` (see the *Rendering Model* design): each + container is laid out in isolation and sized to fit its children. The `CoreOptions.HierarchyHandling` + option records this selection; only the separate-children mode is honored today. + +## HierarchicalLayoutAlgorithm Error Handling + +Null `graph`, `options`, or (injecting constructor) `registry` throw `ArgumentNullException`. A scope +that selects an algorithm identifier absent from the registry surfaces the registry's +`KeyNotFoundException`. Edges whose endpoints are not under the current scope are skipped rather than +treated as errors. + +## HierarchicalLayoutAlgorithm Interactions + +`HierarchicalLayoutAlgorithm` depends on the `ILayoutAlgorithm`, `LayoutAlgorithmRegistry`, +`LayoutGraph`, `LayoutGraphNode`, `LayoutTree`, `CoreOptions`, and related model types, and composes +the public `ConnectorRouter` unit for cross-container routing and the bundled leaf algorithms +(`LayeredLayoutAlgorithm`, `ContainmentLayoutAlgorithm`) for per-scope placement. It is resolvable by +renderers and callers through the layout registry under the `"hierarchical"` identifier, selected via +`CoreOptions.Algorithm`. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-HierarchicalLayout-Identity | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-FlatEquivalence | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-NestsChildren | HierarchicalLayoutAlgorithm behavior described above | +| 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-ValidatesGraph | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-ValidatesOptions | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-ValidatesRegistry | HierarchicalLayoutAlgorithm behavior described above | diff --git a/docs/design/rendering-layout/layered-layout-algorithm.md b/docs/design/rendering-layout/layered-layout-algorithm.md new file mode 100644 index 0000000..f654396 --- /dev/null +++ b/docs/design/rendering-layout/layered-layout-algorithm.md @@ -0,0 +1,53 @@ +# LayeredLayoutAlgorithm Unit Design + +Part of the Rendering Layout system. + +## LayeredLayoutAlgorithm Purpose + +`LayeredLayoutAlgorithm` is the public `ILayoutAlgorithm` implementation and the system's product +boundary. It arranges an input `LayoutGraph` into Sugiyama layers, routes edges orthogonally, and +produces a placed `LayoutTree` of boxes and connectors. It wraps the reusable layered pipeline via +`InterconnectionLayoutEngine`. + +## LayeredLayoutAlgorithm Data Model + +The class is stateless and sealed. It exposes the `AlgorithmId` constant (`"layered"`) and returns it +from the `Id` property, the stable identifier under which the algorithm is selected and registered. +Its single behavior is `Apply(LayoutGraph graph, LayoutOptions options)`, which returns a +`LayoutTree` carrying the total width and height and a flat list of `LayoutNode` items (`LayoutBox` +per node followed by `LayoutLine` per edge). + +## LayeredLayoutAlgorithm Methods + +`Apply(graph, options)` rejects null `graph` or `options` with `ArgumentNullException`, then: + +1. **Index mapping.** Assigns each `LayoutGraphNode` a positional index and builds the + `LayerNode` array from node sizes. +2. **Edge mapping.** Maps each edge to an index pair, dropping any edge that references a node + outside this graph. +3. **Placement.** Calls `InterconnectionLayoutEngine.Place` to obtain the `LayerResult`. +4. **Box emission.** Emits one `LayoutBox` per input node, in input order, at the placed rectangle, + carrying the node label. +5. **Route resolution.** Builds a `(source, target)` to polyline lookup from the engine's acyclic + edge set, then emits one `LayoutLine` per input edge. `ResolveRoute` returns the forward polyline + when present, reverses the polyline of a reversed back edge, and otherwise falls back to a + straight segment between the two node centers (for a self-loop or duplicate edge the engine + dropped) so the connector is still drawn. + +An empty graph yields an empty `LayoutTree` because `InterconnectionLayoutEngine.Place` returns a +minimal-size empty result, which produces an empty canvas. + +## LayeredLayoutAlgorithm Interactions + +`LayeredLayoutAlgorithm` depends on the `ILayoutAlgorithm`, `LayoutGraph`, `LayoutTree`, and related +model types from `DemaConsulting.Rendering.Abstractions` and on `InterconnectionLayoutEngine` from +the Engine subsystem. It is the entry point resolved by renderers through the layout registry. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Layout-LayeredAlgorithm-Identity | LayeredLayoutAlgorithm behavior described above | +| Rendering-Layout-LayeredAlgorithm-PlacesAndRoutes | LayeredLayoutAlgorithm behavior described above | +| Rendering-Layout-LayeredAlgorithm-EmptyGraph | LayeredLayoutAlgorithm behavior described above | +| Rendering-Layout-LayeredAlgorithm-Validation | LayeredLayoutAlgorithm behavior described above | diff --git a/docs/design/rendering-layout/rendering-layout.md b/docs/design/rendering-layout/rendering-layout.md new file mode 100644 index 0000000..f8b0967 --- /dev/null +++ b/docs/design/rendering-layout/rendering-layout.md @@ -0,0 +1,76 @@ +# Rendering.Layout Design + +## Overview + +`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. Reusable +geometric engines provide orthogonal routing, containment packing, and layered interconnection placement, +while the default facade resolves the requested algorithm from the bundled registry. + +## Software Structure + +```text +DemaConsulting.Rendering.Layout (System) +├── Engine (Subsystem) +│ ├── OrthogonalEdgeRouter (Unit) +│ ├── ContainmentPacker (Unit) +│ ├── InterconnectionLayoutEngine (Unit) +│ └── LayeredPipeline (Unit) +├── EdgeRoutingOption (Unit) +├── ConnectorRouter (Unit) +├── ContainmentLayout (Unit) +├── ContainmentLayoutAlgorithm (Unit) +├── HierarchicalLayoutAlgorithm (Unit) +├── DefaultLayout (Unit) +└── LayeredLayoutAlgorithm (Unit) +``` + +- **Engine** - reusable model-agnostic geometric engines. Detailed in + Engine Subsystem Design, which links to its unit designs. +- **EdgeRoutingOption** - routing-style configuration keys. Detailed in + EdgeRoutingOption Unit Design. +- **ConnectorRouter** - routes connectors among already placed boxes. Detailed in + ConnectorRouter Unit Design. +- **ContainmentLayout** - packs already sized model boxes into a container region. Detailed in + ContainmentLayout Unit Design. +- **ContainmentLayoutAlgorithm** - public containment algorithm. Detailed in + ContainmentLayoutAlgorithm Unit Design. +- **HierarchicalLayoutAlgorithm** - recursive compound-graph algorithm. Detailed in + HierarchicalLayoutAlgorithm Unit Design. +- **DefaultLayout** - bundled registry factory and layout facade. Detailed in + DefaultLayout Unit Design. +- **LayeredLayoutAlgorithm** - public layered algorithm. Detailed in + LayeredLayoutAlgorithm Unit Design. + +## System Interactions + +The system consumes `LayoutGraph`, `LayoutOptions`, `CoreOptions`, and `LayoutTree` from the Rendering +model and implements `ILayoutAlgorithm` from Rendering.Abstractions. Callers may use the bundled registry +and `LayoutEngine` facade, or resolve a specific bundled algorithm directly. Renderers consume only the +placed tree produced by this system; they do not call the geometric engines directly. + +The Engine subsystem operates on model-agnostic geometry and is composed by the public algorithms and +routing helpers. The hierarchical algorithm composes leaf algorithms per container and routes +cross-container edges at the owning scope. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| `Rendering-Layout-Algorithm` | LayeredLayoutAlgorithm | +| `Rendering-Layout-GeometricEngines` | Engine Subsystem | +| `Rendering-Layout-StagedPipeline` | LayeredPipeline | +| `Rendering-Layout-ConnectorRouting` | EdgeRoutingOption, ConnectorRouter | +| `Rendering-Layout-ContainmentPlacement` | ContainmentLayout | +| `Rendering-Layout-ContainmentAlgorithm` | ContainmentLayoutAlgorithm | +| `Rendering-Layout-HierarchicalLayout` | HierarchicalLayoutAlgorithm | +| `Rendering-Layout-DefaultLayout` | DefaultLayout | + +## Scope Exclusions + +Per-unit data models, algorithms, error handling, and design constraints are intentionally excluded from +this system document and live in the unit and subsystem documents linked above. Test projects and test +infrastructure are verification artifacts, not product design scope. diff --git a/docs/design/rendering-skia/jpeg-renderer.md b/docs/design/rendering-skia/jpeg-renderer.md new file mode 100644 index 0000000..707d4f6 --- /dev/null +++ b/docs/design/rendering-skia/jpeg-renderer.md @@ -0,0 +1,31 @@ +# JpegRenderer Unit Design + +Part of the Rendering.Skia system. + +## JpegRenderer Overview + +`JpegRenderer` is the concrete raster renderer for lossy JPEG output. It derives from +`SkiaRasterRenderer` and supplies only the JPEG format-selection metadata and lossy encoding quality; +all drawing behaviour comes from the shared rasterizer. + +## JpegRenderer Data Model + +| Member | Value | +| --- | --- | +| `EncodedFormat` | `SKEncodedImageFormat.Jpeg` | +| `EncodingQuality` | `90` | +| `MediaType` | `image/jpeg` | +| `DefaultExtension` | `.jpg` | +| `FileExtensions` | `.jpg`, `.jpeg` | + +## JpegRenderer Interactions + +A `RendererRegistry` can resolve `JpegRenderer` by the `image/jpeg` media type or by either advertised +file extension. Because JPEG has no transparency channel, it relies on the inherited rasterizer's theme +background initialization before encoding. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Skia-JpegRenderer-EmitsJpeg | JPEG format, quality, media type, and extension overrides | diff --git a/docs/design/rendering-skia/png-renderer.md b/docs/design/rendering-skia/png-renderer.md new file mode 100644 index 0000000..feb6c8f --- /dev/null +++ b/docs/design/rendering-skia/png-renderer.md @@ -0,0 +1,31 @@ +# PngRenderer Unit Design + +Part of the Rendering.Skia system. + +## PngRenderer Overview + +`PngRenderer` is the concrete raster renderer for lossless PNG output. It derives from +`SkiaRasterRenderer` and supplies only the PNG format-selection metadata; all drawing behaviour comes +from the shared rasterizer. + +## PngRenderer Data Model + +| Member | Value | +| --- | --- | +| `EncodedFormat` | `SKEncodedImageFormat.Png` | +| `EncodingQuality` | inherited default `100` | +| `MediaType` | `image/png` | +| `DefaultExtension` | `.png` | +| `FileExtensions` | `.png` | + +## PngRenderer Interactions + +A `RendererRegistry` can resolve `PngRenderer` by the `image/png` media type or by the `.png` file +extension. After resolution, callers invoke the inherited `Render` method and receive a PNG byte stream +written to their output stream. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Skia-PngRenderer-EmitsPng | PNG format, media type, and extension overrides | diff --git a/docs/design/rendering-skia/rendering-skia.md b/docs/design/rendering-skia/rendering-skia.md new file mode 100644 index 0000000..d32fed0 --- /dev/null +++ b/docs/design/rendering-skia/rendering-skia.md @@ -0,0 +1,52 @@ +# Rendering.Skia Design + +## Overview + +The `DemaConsulting.Rendering.Skia` system renders a placed `LayoutTree` to raster images using +[SkiaSharp](https://github.com/mono/SkiaSharp). A shared `SkiaRasterRenderer` base performs all drawing; +thin concrete renderers select the encoded output format. + +The system draws a diagram once onto a SkiaSharp bitmap and then encodes it in a concrete image format. +All drawing logic - boxes, labels, connectors with end markers, ports, badges, bands, lifelines, +activations, and grids - lives in the abstract `SkiaRasterRenderer`. Each concrete renderer +(`PngRenderer`, `JpegRenderer`, `WebpRenderer`) supplies only the SkiaSharp encoded-image format, +encoding quality, media type, and file extensions. This keeps the multi-format surface a few lines per +format while guaranteeing that PNG, JPEG, and WEBP output share the same raster drawing path apart from +the final encode step. + +An embedded Noto Sans font (SIL OFL 1.1) is used for all text so that raster output is byte-reproducible +across platforms regardless of installed fonts. SkiaSharp is MIT-licensed. + +## Software Structure + +```text +Rendering.Skia (System) +├── SkiaRasterRenderer (Unit) - abstract base: rasterizes a LayoutTree to a bitmap and encodes it +├── PngRenderer (Unit) - lossless PNG output +├── JpegRenderer (Unit) - lossy JPEG output +└── WebpRenderer (Unit) - WEBP output +``` + +- **SkiaRasterRenderer** - the shared SkiaSharp rasterizer that allocates the bitmap, initializes it + with the render theme's background color, draws layout-tree nodes, and encodes the bitmap using the + derived renderer's format. Detailed in SkiaRasterRenderer Unit Design. +- **PngRenderer** - the concrete renderer that emits lossless PNG output. Detailed in + PngRenderer Unit Design. +- **JpegRenderer** - the concrete renderer that emits lossy JPEG output. Detailed in + JpegRenderer Unit Design. +- **WebpRenderer** - the concrete renderer that emits WEBP output. Detailed in + WebpRenderer Unit Design. + +## System Interactions + +The system consumes the `LayoutTree` and node records from the Rendering model, and the `IRenderer`, +`RenderOptions`, `Theme`, `NotationMetrics`, `BoxMetrics`, and `ConnectorLabelPlacer` helpers from the +Rendering.Abstractions system. The three concrete renderers have no interactions with each other; each +is a leaf that inherits all drawing behaviour from `SkiaRasterRenderer` and differs only in the encoded +output format it selects. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Skia-RenderRasterImage | `SkiaRasterRenderer` rasterization plus the three format encoders | diff --git a/docs/design/rendering-skia/skia-raster-renderer.md b/docs/design/rendering-skia/skia-raster-renderer.md new file mode 100644 index 0000000..4edb7ad --- /dev/null +++ b/docs/design/rendering-skia/skia-raster-renderer.md @@ -0,0 +1,61 @@ +# SkiaRasterRenderer Unit Design + +Part of the Rendering.Skia system. + +## SkiaRasterRenderer Overview + +`SkiaRasterRenderer` is the abstract `IRenderer` implementation that provides the common SkiaSharp +rasterization path for every raster format. Concrete renderers supply the encoded image format, quality, +media type, and file extensions; the base class owns argument validation, bitmap allocation, canvas +initialization, node drawing, connector-label finalization, and stream encoding. + +## SkiaRasterRenderer Data Model + +| Member | Type | Description | +| --- | --- | --- | +| `EncodedFormat` | `abstract SKEncodedImageFormat` | The SkiaSharp encode format supplied by the concrete renderer. | +| `EncodingQuality` | `virtual int` (default 100) | Quality passed to the encoder; used by lossy formats. | +| `MediaType` | `abstract string` | The MIME media type reported to callers and registries. | +| `DefaultExtension` | `abstract string` | The primary output file extension. | +| `FileExtensions` | `abstract IReadOnlyList` | Every file extension the concrete renderer produces. | + +## SkiaRasterRenderer Methods + +- **`Render(LayoutTree, RenderOptions, Stream)`** - validates its arguments, allocates an `SKBitmap` + sized from `LayoutTree.Width`/`LayoutTree.Height` scaled by `RenderOptions.Scale` (minimum one by one + pixel), clears the bitmap to `RenderOptions.Theme.BackgroundColor`, draws every node, draws connector + labels in a final pass, and writes the encoded bytes to the stream without closing or flushing it. +- **Node drawing helpers** - draw boxes, labels, lines, ports, badges, bands, lifelines, activations, + and grids using the render theme, the embedded Noto Sans typefaces, shared notation metrics, and + shared box metrics. +- **Connector helpers** - build connector end markers from `NotationMetrics` so raster markers match + the SVG renderer. Hollow marker interiors and midpoint-label backplates use `Theme.BackgroundColor` + so they occlude connector strokes with the same background used for the bitmap. + +## SkiaRasterRenderer Design Constraints + +- The rasterizer shall enforce a minimum bitmap size of one by one pixels before allocating the + `SKBitmap`. +- The rasterizer shall use the render theme as the single source of truth for the canvas background, + hollow-marker occlusion fill, and midpoint-label backplate fill. +- Box and grid fills shall be selected from `Theme.DepthFillColors` by layout depth. +- Connector end-marker geometry shall derive from `NotationMetrics`, matching the SVG renderer's + marker dimensions. +- The output stream remains owned by the caller; the renderer writes encoded bytes but does not close + or flush the stream. + +## SkiaRasterRenderer Interactions + +`SkiaRasterRenderer` consumes model nodes from the Rendering system and `RenderOptions`, `Theme`, +`NotationMetrics`, `BoxMetrics`, and `ConnectorLabelPlacer` from the Rendering.Abstractions system. It +is inherited by `PngRenderer`, `JpegRenderer`, and `WebpRenderer`, which provide only format-selection +members. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Skia-SkiaRasterRenderer-DrawsLayoutTree | `Render`, node drawing, background, markers, and labels | +| Rendering-Skia-SkiaRasterRenderer-ThemeColours | Box and grid fill selection from `Theme.DepthFillColors` | +| Rendering-Skia-SkiaRasterRenderer-EndMarkers | End-marker drawing helpers that use `NotationMetrics` | +| Rendering-Skia-SkiaRasterRenderer-EmptyTree | Minimum bitmap width and height enforcement in `Render` | diff --git a/docs/design/rendering-skia/webp-renderer.md b/docs/design/rendering-skia/webp-renderer.md new file mode 100644 index 0000000..c32ab17 --- /dev/null +++ b/docs/design/rendering-skia/webp-renderer.md @@ -0,0 +1,31 @@ +# WebpRenderer Unit Design + +Part of the Rendering.Skia system. + +## WebpRenderer Overview + +`WebpRenderer` is the concrete raster renderer for WEBP output. It derives from `SkiaRasterRenderer` +and supplies only the WEBP format-selection metadata and encoding quality; all drawing behaviour comes +from the shared rasterizer. + +## WebpRenderer Data Model + +| Member | Value | +| --- | --- | +| `EncodedFormat` | `SKEncodedImageFormat.Webp` | +| `EncodingQuality` | `90` | +| `MediaType` | `image/webp` | +| `DefaultExtension` | `.webp` | +| `FileExtensions` | `.webp` | + +## WebpRenderer Interactions + +A `RendererRegistry` can resolve `WebpRenderer` by the `image/webp` media type or by the `.webp` file +extension. After resolution, callers invoke the inherited `Render` method and receive a WEBP container +written to their output stream. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Skia-WebpRenderer-EmitsWebp | WEBP format, quality, media type, and extension overrides | diff --git a/docs/design/rendering-svg/rendering-svg.md b/docs/design/rendering-svg/rendering-svg.md new file mode 100644 index 0000000..56fa1a8 --- /dev/null +++ b/docs/design/rendering-svg/rendering-svg.md @@ -0,0 +1,43 @@ +# Rendering.Svg Design + +## Overview + +`DemaConsulting.Rendering.Svg` is the SVG renderer system. It translates a placed +`LayoutTree` from the Rendering model into a self-contained SVG 1.1 document written to a +caller-supplied stream. The system has one product unit, `SvgRenderer`, and no runtime dependencies +beyond the .NET base class library and the in-house Rendering model and abstractions packages. + +The renderer system is responsible for vector output only. Layout placement, the `LayoutTree` data +model, renderer contracts, themes, and shared notation geometry are defined by upstream systems and are +consumed here through their public APIs. + +## Software Structure + +```text +DemaConsulting.Rendering.Svg (System) +└── SvgRenderer (Unit) +``` + +- **SvgRenderer** - writes SVG markup for placed layout nodes, connector paths, labels, and end-marker + definitions. Detailed in SvgRenderer Unit Design. + +## System Interactions + +Callers provide a `LayoutTree`, `RenderOptions`, and output `Stream` through the shared `IRenderer` +contract from Rendering.Abstractions. `SvgRenderer` reads model nodes from Rendering, uses the supplied +`Theme` and shared notation/box/label geometry helpers from Rendering.Abstractions, and writes UTF-8 SVG +bytes to the caller-owned stream. + +The SVG system does not choose a layout algorithm and does not mutate the model. A typical pipeline runs +Rendering.Layout first to place a graph, then passes the resulting tree to this renderer. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| `Rendering-Svg-WriteSvgDocument` | SvgRenderer Unit Design | + +## Scope Exclusions + +Detailed SVG element generation, font styling, text fitting, XML escaping, and marker geometry are unit +scope and live in SvgRenderer Unit Design. diff --git a/docs/design/rendering-svg/svg-renderer.md b/docs/design/rendering-svg/svg-renderer.md new file mode 100644 index 0000000..f93e473 --- /dev/null +++ b/docs/design/rendering-svg/svg-renderer.md @@ -0,0 +1,212 @@ +# SvgRenderer Unit Design + +Part of the Rendering.Svg system. + +## SvgRenderer Overview + +`SvgRenderer` implements the `IRenderer` interface to produce SVG 1.1 diagram output from a +`LayoutTree` intermediate representation. Each call to `Render` builds a complete SVG document in a +`StringBuilder` and writes it to the supplied stream in UTF-8 encoding. The renderer is pure and +stateless; no fields are mutated between calls, so a single instance may be reused concurrently. + +## SvgRenderer Data Model + +`SvgRenderer` has no instance state. All inputs are supplied through `Render` parameters. + +- `LayoutTree` — read-only input; canvas dimensions and the list of placed nodes. +- `RenderOptions` — read-only input; `Theme` for visual parameters and `Scale` for sizing. +- `Stream output` — write-only output; receives UTF-8 SVG bytes; caller owns lifetime. + +## Font Family + +All text elements use `font-family="Noto Sans, sans-serif"`. The `Noto Sans` family is specified +first so that browsers and renderers with Noto Sans installed use it; `sans-serif` is the CSS generic +fallback. Naming Noto Sans first keeps the SVG output visually aligned with the PNG renderer, which +embeds the same font for pixel-identical rasterization. + +## Font Weight and Style Per Node Type + +Each node type uses a fixed font weight and style as SVG attributes: + +| Node Type | `font-weight` | `font-style` | +| --- | --- | --- | +| `LayoutBox` label | `bold` | (default) | +| `LayoutBoxCompartment` title | `bold` | `italic` | +| `LayoutBoxCompartment` rows | (default) | (default) | +| `LayoutLine` connector label | (default) | (default) | +| `LayoutLabel` | Per `Weight` field | Per `Style` field | +| `LayoutPort` label | (default) | (default) | +| `LayoutBadge` label | (default) | (default) | +| `LayoutBand` label | (default) | (default) | +| `LayoutLifeline` label | `bold` | (default) | +| `LayoutGrid` header cells | `bold` | (default) | +| `LayoutGrid` body cells | (default) | (default) | + +## LayoutLabel Font Styling Fields + +`LayoutLabel` carries three explicit font styling fields: + +- `Weight` (`FontWeight`) — `Regular` maps to `font-weight="normal"`; `Bold` maps to + `font-weight="bold"`. +- `Style` (`FontStyle`) — `Normal` maps to `font-style="normal"`; `Italic` maps to + `font-style="italic"`. +- `FontSize` (double) — font size in logical pixels, used as `font-size` instead of the theme body + size. + +## Text Length Shrink-to-Fit + +`LayoutBox` labels and `LayoutLabel` nodes are constrained to their available width only when the text +would otherwise overflow it. `FitTextLength` estimates each label's natural width (character count +multiplied by font size and an average glyph-width factor) and compares it to the available width: +`box.Width - 2 * theme.LabelPadding` for box labels, or `MaxWidth` for a `LayoutLabel`. Only when the +estimate exceeds the available width does the renderer emit `textLength="{availableWidth * scale}"` +together with `lengthAdjust="spacingAndGlyphs"`, which instructs SVG viewers to compress the glyph +spacing so the text shrinks into the available area without overflow. + +When the text already fits, or the available width is non-positive, no `textLength` or `lengthAdjust` +attribute is emitted, so short labels render at their natural width and are never stretched to fill +the box. + +## Key Methods + +**`Render(LayoutTree layout, RenderOptions options, Stream output)`** + +Entry point. Validates arguments, computes canvas size clamped to a minimum of 1x1, writes the SVG root +element with `xmlns`, `width`, `height`, and `viewBox` attributes, then calls `WriteEndMarkerDefs`. +It recursively renders every top-level node; connector paths are drawn during this pass, but connector +labels are deferred. After all nodes are drawn, `Render` collects every `LayoutLine`, asks +`ConnectorLabelPlacer.Place` for collision-aware label positions, renders each non-null connector +label in a final pass, closes the SVG root, encodes the completed `StringBuilder` as UTF-8, and writes +all bytes to `output` in a single `Write` call. + +**`WriteEndMarkerDefs(StringBuilder sb, Theme theme)`** + +Writes the SVG `` block containing the named line-end marker elements, including +`line-end-open-chevron`, `line-end-hollow-triangle`, `line-end-filled-arrow`, +`line-end-hollow-diamond`, `line-end-filled-diamond`, `line-end-circle`, `line-end-bar`, and +`line-end-hollow-triangle-crossbar`. All markers use `theme.StrokeColor` and `theme.StrokeWidth`. +The `` block also defines the `label-bg` filter used behind connector labels. + +Every marker coordinate is derived from the single-source notation geometry in `NotationMetrics`, for +example `EndMarkerLength`, `EndMarkerWidth`, `EndMarkerRefX`, `DiamondLength`, `CircleRadius`, and +the `TriangleVertices()` / `DiamondVertices()` helpers that return `MarkerVertex` values. Because the +SVG and PNG renderers read from the same `NotationMetrics` source in +`DemaConsulting.Rendering.Abstractions`, their end markers are geometrically identical. +`line-end-open-chevron` is the only open marker: it omits the closing base edge so the chevron renders +as two strokes meeting at the apex; the hollow triangle and both diamonds remain closed. + +**`RenderNode(StringBuilder sb, LayoutNode node, Theme theme, double scale)`** + +Dispatches by concrete node type to the appropriate typed render method. All nine `LayoutNode` +subtypes are handled: `LayoutBox` → `RenderBox`, `LayoutLine` → `RenderLine`, `LayoutLabel` → +`RenderLabel`, `LayoutPort` → `RenderPort`, `LayoutBadge` → `RenderBadge`, `LayoutBand` → +`RenderBand`, `LayoutLifeline` → `RenderLifeline`, `LayoutActivation` → `RenderActivation`, and +`LayoutGrid` → `RenderGrid`. Unknown subtypes are silently skipped for forward compatibility. + +**`RenderBox(StringBuilder sb, LayoutBox box, Theme theme, double scale)`** + +Selects the fill color from `theme.DepthFillColors[box.Depth % count]` and delegates to +`RenderBoxOutline`, `RenderFolderOutline`, or `RenderNoteOutline` depending on `box.Shape`. The +rectangle outline adds `rx` / `ry` attributes when `BoxShape.RoundedRectangle` and `LineCornerRadius` +is greater than zero. Calls `RenderBoxTitle` to write the bold `` title with `textLength` from +`FitTextLength` when `box.Label` is non-null, then `RenderBoxCompartments` for any compartments, then +recursively calls `RenderNode` for all `box.Children`. + +**`RenderBoxCompartments(StringBuilder sb, LayoutBox box, Theme theme, double scale)`** + +Writes a `` divider across the full box width at the top of each compartment, followed by an +optional `font-weight="bold" font-style="italic"` `` title row and zero or more left-aligned +regular-weight body-font `` rows. + +**`RenderLine(StringBuilder sb, LayoutLine line, Theme theme, double scale)`** + +Calls `BuildLinePath` to produce the path `d` attribute, then writes a `` element with +`fill="none"`. Adds `marker-start` or `marker-end` attributes for the non-None `EndMarkerStyle` +values, referencing the `SourceEnd` and `TargetEnd` line-end markers. Adds `stroke-dasharray` for +`Dashed` and `Dotted` line styles. `RenderLine` intentionally does not draw `MidpointLabel`; connector +labels are drawn in the final pass of `Render` so later wires cannot cover earlier labels. + +**`BuildLinePath(...)`** + +Builds the SVG path `d` string. When `cornerRadius` is zero, emits plain `M` / `L` commands. When +positive, each interior waypoint is replaced with a shortened `L` command to the arc start point, +followed by an `A` (elliptical arc) command whose sweep direction is determined from the cross product +of the incoming and outgoing unit direction vectors. The radius is clamped to half the shorter +adjacent segment to prevent overshoot. At the first and last bends the radius is additionally clamped +so the rounded corner completes at least the marker's along-line length +(`NotationMetrics.AlongLineLength` for the `SourceEnd` / `TargetEnd` styles) before the endpoint, so +the curve never intrudes into the end-marker zone. + +**`RenderLineLabel(StringBuilder sb, LayoutLine line, Theme theme, double scale, double midX, double midY)`** + +Writes a connector label's `` element at the position supplied by `ConnectorLabelPlacer.Place`. +The text is centered with `text-anchor="middle"` and `dominant-baseline="middle"`, uses the theme body +font size and stroke color, applies `filter="url(#label-bg)"` so the label has an auto-sizing white +background, and escapes XML-special characters in `line.MidpointLabel`. + +**`RenderLabel(StringBuilder sb, LayoutLabel label, Theme theme, double scale)`** + +Writes a `` element with `text-anchor` from `label.Align`, `font-size` from `label.FontSize`, and +`font-weight` / `font-style` from `label.Weight` and `label.Style`. When `label.MaxWidth > 0`, adds +`textLength` and `lengthAdjust="spacingAndGlyphs"`. + +**`RenderPort` / `RenderBadge` / `RenderBand` / `RenderLifeline` / `RenderActivation` / +`RenderGrid`** + +Each typed method writes the SVG elements appropriate to its node kind: an 8x8 `` for ports; +``, ``, or `` for the badge shapes; a `` with a rotated or horizontal +`` for bands; a header ``, bold ``, and dashed `` stem for lifelines; a white +`` border for activations; and per-cell `` plus `` elements for grids. Fills come +from `theme.DepthFillColors`, and header cells add `font-weight="bold"`. + +## Error Handling + +`Render` throws `ArgumentNullException` when `layout`, `options`, or `output` is null. No other +exceptions are expected under normal operation. XML special characters in labels are escaped via +`EscapeXml` to prevent malformed SVG output. + +## Dependencies + +- `DemaConsulting.Rendering` — provides `LayoutTree` and all nine `LayoutNode` subtypes. +- `DemaConsulting.Rendering.Abstractions` — provides `IRenderer`, `RenderOptions`, `Theme`, `Themes`, + `NotationMetrics`, `BoxMetrics`, `MarkerVertex`, and `ConnectorLabelPlacer`. + +## Callers + +Any consumer of the rendering library that selects vector output constructs an `SvgRenderer` and calls +`IRenderer.Render` with a placed `LayoutTree` and `RenderOptions`. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Svg-SvgRenderer-ImplementsIRenderer | `SvgRenderer : IRenderer` | +| Rendering-Svg-SvgRenderer-MediaType | `MediaType` property | +| Rendering-Svg-SvgRenderer-DefaultExtension | `DefaultExtension` property | +| Rendering-Svg-SvgRenderer-RenderDocument | `Render` writes the SVG root element | +| Rendering-Svg-SvgRenderer-RenderEmptyTree | `Render` clamps canvas size and emits an empty SVG root | +| Rendering-Svg-SvgRenderer-RenderBox | `RenderBox` writes the box rectangle outline | +| Rendering-Svg-SvgRenderer-RenderBoxRoundedCorners | `RenderBoxOutline` writes rounded-corner attributes | +| Rendering-Svg-SvgRenderer-RenderBoxCompartments | `RenderBoxCompartments` writes dividers and row text | +| Rendering-Svg-SvgRenderer-RenderLabel | `RenderLabel` writes a text element | +| Rendering-Svg-SvgRenderer-RenderLabelBold | `RenderLabel` maps `FontWeight.Bold` | +| Rendering-Svg-SvgRenderer-RenderLabelItalic | `RenderLabel` maps `FontStyle.Italic` | +| Rendering-Svg-SvgRenderer-RenderLabelEscaping | `EscapeXml` protects label text | +| Rendering-Svg-SvgRenderer-RenderLine | `RenderLine` writes the connector path | +| Rendering-Svg-SvgRenderer-RenderLineRoundedCorners | `BuildLinePath` emits arc commands | +| Rendering-Svg-SvgRenderer-RenderLineDashed | `RenderLine` writes `stroke-dasharray` | +| Rendering-Svg-SvgRenderer-RenderLineMidpointLabel | `ConnectorLabelPlacer` plus `RenderLineLabel` | +| Rendering-Svg-SvgRenderer-RenderNodeKinds | `RenderPort` writes the port rectangle | +| Rendering-Svg-SvgRenderer-RenderBadge | `RenderBadge` writes filled-circle badges | +| Rendering-Svg-SvgRenderer-RenderBand | `RenderBand` writes the band rectangle | +| Rendering-Svg-SvgRenderer-RenderLifeline | `RenderLifeline` writes the header and stem | +| Rendering-Svg-SvgRenderer-RenderActivation | `RenderActivation` writes the activation rectangle | +| Rendering-Svg-SvgRenderer-RenderGrid | `RenderGrid` writes cell rectangles | +| Rendering-Svg-SvgRenderer-EndMarkers | `WriteEndMarkerDefs` defines the open chevron polyline | +| Rendering-Svg-SvgRenderer-EndMarkersOpenChevronReference | `RenderLine` references `line-end-open-chevron` | +| Rendering-Svg-SvgRenderer-HollowTriangleEndMarkers | `WriteEndMarkerDefs` defines the hollow triangle polygon | +| Rendering-Svg-SvgRenderer-HollowTriangleEndMarkerReference | `RenderLine` writes a hollow-triangle `marker-end` | +| Rendering-Svg-SvgRenderer-TriangleEndMarkerMetrics | `WriteEndMarkerDefs` reads triangle `NotationMetrics` | +| Rendering-Svg-SvgRenderer-DiamondEndMarkers | `WriteEndMarkerDefs` reads diamond `NotationMetrics` | +| Rendering-Svg-SvgRenderer-DiamondEndMarkerReference | `RenderLine` writes the hollow-diamond marker reference | +| Rendering-Svg-SvgRenderer-CrossbarEndMarkers | `RenderLine` writes the crossbar marker reference | diff --git a/docs/design/rendering/demo.md b/docs/design/rendering/demo.md deleted file mode 100644 index a4f1248..0000000 --- a/docs/design/rendering/demo.md +++ /dev/null @@ -1,55 +0,0 @@ -# Demo - -The `Demo` class is the sole software unit in the Rendering library. It serves as a -demonstration of how a DEMA Consulting .NET library class should be structured, documented, -and tested. - -## Overview - -`Demo` is a simple greeting class that constructs a greeting string by combining a configurable -prefix with a caller-supplied name. It has no external dependencies and performs no I/O. Instances -are immutable after construction and are therefore safe for concurrent access. - -## Data Model - -| Field | Type | Description | -|-----------------|----------------|--------------------------------------------------| -| `_prefix` | `string` | The greeting prefix stored at construction time. | -| `DefaultPrefix` | `const string` | The default greeting prefix (`"Hello"`). | - -## Methods - -### Demo() - -Default constructor. Delegates to `Demo(string prefix)` with `DefaultPrefix` as the argument. - -### Demo(string prefix) - -Custom-prefix constructor. Validates that `prefix` is non-null and non-empty using -`ArgumentException.ThrowIfNullOrEmpty`, then stores it in `_prefix`. - -**Throws:** - -- `ArgumentNullException` — when `prefix` is `null` -- `ArgumentException` — when `prefix` is an empty string - -### DemoMethod(string name) - -Returns a greeting string in the format `"{prefix}, {name}!"`. Validates that `name` is -non-null and non-empty using `ArgumentException.ThrowIfNullOrEmpty`. - -**Throws:** - -- `ArgumentNullException` — when `name` is `null` -- `ArgumentException` — when `name` is an empty string - -**Returns:** A `string` in the format `"{prefix}, {name}!"`. - -### Prefix - -Read-only property that exposes `_prefix` to callers. - -## Interactions - -`Demo` has no interactions with other units. It is a self-contained leaf class with no -dependencies beyond the .NET base class library. diff --git a/docs/design/rendering/layout-graph.md b/docs/design/rendering/layout-graph.md new file mode 100644 index 0000000..0e09136 --- /dev/null +++ b/docs/design/rendering/layout-graph.md @@ -0,0 +1,95 @@ +# Layout Graph Unit Design + +Part of the Rendering Model system. + +## Layout Graph Overview + +The Layout Graph unit is the unplaced input to a layout algorithm: a set of sized `LayoutGraphNode` +boxes and directed `LayoutGraphEdge` connections. `LayoutGraph` itself derives from `PropertyHolder`, +so graph-wide options may be attached directly to it, and each node and edge also carries its own +property overrides. + +The input model is **hierarchical (recursive)**, mirroring the Eclipse Layout Kernel (ELK) +`ElkNode`: a node is either a *leaf* or a *container* (compound node) that owns a nested child +subgraph of further nodes and the edges contained at that level. A `LayoutGraphNode` becomes a +container by populating its `Children` graph, which is an ordinary `LayoutGraph`. Hierarchy is thus +expressed by recursion over a single container type rather than by a distinct nested-graph type: the +`LayoutGraph` a caller creates directly is the top-level (root) container, and each container node's +`Children` is a container nested one level deeper. + +The nesting is **additive and behavior-preserving**. The child subgraph is created lazily, so a leaf +node allocates no child graph and a flat (non-nested) graph behaves exactly as it did before nesting +existed. A layout algorithm that reads only the top-level `Nodes` and `Edges` — such as the bundled +`LayeredLayoutAlgorithm` — is unaffected by the presence of the capability; consuming the nesting is +the responsibility of a hierarchical layout engine (a later delivery). + +## Layout Graph Data Model + +- `LayoutGraph` (sealed class extending `PropertyHolder`) — `Nodes`, `Edges`, `AddNode`, `AddEdge`; + each instance is a container scope (the root graph, or a node's `Children`). +- `LayoutGraphNode` (sealed class extending `PropertyHolder`) — `Id`, `Width`, `Height`, `Label`, + `Children` (the lazily-created nested child subgraph), and `HasChildren` (whether the node holds at + least one child). +- `LayoutGraphEdge` (sealed class extending `PropertyHolder`) — `Id`, `Source`, `Target`, + `TargetEnd`, `LineStyle`, `Label`. + +## Layout Graph Key Methods + +`LayoutGraphNode AddNode(string id, double width, double height)` — creates a node with the requested +identity and size, appends it to `Nodes` in insertion order, and returns it for further +configuration. + +`LayoutGraphEdge AddEdge(string id, LayoutGraphNode source, LayoutGraphNode target)` — creates a +directed edge referencing the two endpoint nodes, appends it to `Edges`, and returns it. The +`LayoutGraphEdge` constructor rejects a null identifier, source, or target. The endpoints need not be +direct members of the graph the edge is added to, which is what makes cross-container edges +expressible (see the design constraints below). + +`LayoutGraph LayoutGraphNode.Children { get; }` — the nested child subgraph, created lazily on first +access, through which a caller adds nested nodes and contained edges (`node.Children.AddNode(...)`, +`node.Children.AddEdge(...)`), reusing the container's identifier-uniqueness and insertion-order +guarantees. + +`bool LayoutGraphNode.HasChildren { get; }` — reports whether the node currently holds at least one +child without forcing the lazy allocation, so consumers can distinguish a container from a leaf and +skip empty containers. + +## Layout Graph Design Constraints + +- `AddNode` and `AddEdge` shall preserve caller insertion order in `Nodes` and `Edges`, so that a + layout algorithm processes elements deterministically. +- `Nodes` and `Edges` shall be exposed as read-only views (`IReadOnlyList`); all mutation shall go + through `AddNode` and `AddEdge`, so a caller cannot bypass the per-container identifier-uniqueness + guarantee by appending directly. +- A `LayoutGraphNode` identifier shall be unique within its owning graph, and an empty identifier + shall be rejected at construction. +- Identifier uniqueness shall be scoped **per container**: each `LayoutGraph` (the root, or any node's + `Children`) shall enforce its own node- and edge-identifier uniqueness, so an identifier may be + reused across different scopes but not twice within one scope. +- A leaf node shall allocate no child subgraph, so that adding the hierarchy capability leaves a flat + graph's structure and layout unchanged; `HasChildren` shall not force that allocation. +- An edge shall reside in the container at or above the *lowest common ancestor* (LCA) of its two + endpoints. An edge whose endpoints live in different descendant containers (a *cross-container* + edge) shall therefore be added to an ancestor container while its `Source` and `Target` reference + the descendant nodes directly; the model shall not add membership validation that would forbid such + cross-scope references. A hierarchical layout engine resolves the routing in the owning container's + coordinate space. + +## Layout Graph Interactions + +A `LayoutGraph` plus a `LayoutOptions` is the input to `ILayoutAlgorithm.Apply` (see the +*Rendering Abstractions* design), which produces a placed `LayoutTree`. Each `LayoutGraphEdge` +carries an `EndMarkerStyle` and `LineStyle` from the Layout Tree unit's enumerations. The bundled +`LayeredLayoutAlgorithm` (see the *Rendering Layout* design) consumes only the top-level `Nodes` and +`Edges`; the recursive `Children` structure is intended for a future hierarchical layout engine. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Model-LayoutGraph-AddNode | `LayoutGraph.AddNode` | +| Rendering-Model-LayoutGraph-AddEdge | `LayoutGraph.AddEdge` | +| Rendering-Model-LayoutGraph-PerElementProperties | `LayoutGraphNode` : `PropertyHolder` | +| Rendering-Model-LayoutGraph-ContainerNodes | `LayoutGraphNode.Children` / `HasChildren` | +| Rendering-Model-LayoutGraph-ScopedIdentifiers | Per-`LayoutGraph` id-uniqueness reused by `Children` | +| Rendering-Model-LayoutGraph-CrossContainerEdge | `LayoutGraphEdge` endpoints referencing descendant nodes | diff --git a/docs/design/rendering/layout-tree.md b/docs/design/rendering/layout-tree.md new file mode 100644 index 0000000..40d5e65 --- /dev/null +++ b/docs/design/rendering/layout-tree.md @@ -0,0 +1,77 @@ +# Layout Tree Unit Design + +Part of the Rendering Model system. + +## Layout Tree Overview + +The Layout Tree unit is the placed, renderer-agnostic representation of one view. A `LayoutTree` +carries the canvas dimensions and a flat list of top-level `LayoutNode` instances. `LayoutNode` is an +abstract record acting as the root of a discriminated union; renderers switch on the concrete type +and skip unknown subtypes for forward compatibility. All coordinates are absolute, with the origin at +the top-left, so a renderer can draw each element directly without resolving nested offsets. + +## Layout Tree Data Model + +- `LayoutTree` (sealed record) — `Width`, `Height`, the top-level `Nodes` list, and layout-quality + `Warnings`. +- `LayoutNode` (abstract record) — the discriminated-union root; carries no members. +- `LayoutBox` (node record) — `X`, `Y`, `Width`, `Height`, `Label`, integer `Depth`, `Shape`, + `Compartments`, `Children`, and optional `Keyword`. +- `LayoutCompartment` (sealed record) — `Title` and text `Rows`. +- `LayoutPort` (node record) — `CentreX`, `CentreY`, attached `Side`, and `Label`. +- `LayoutLine` (node record) — `Waypoints`, `SourceEnd`, `TargetEnd`, `LineStyle`, and + `MidpointLabel`. +- `Point2D` (sealed record) — `X` and `Y`. +- `Rect` (readonly record struct) — `X`, `Y`, `Width`, `Height`; the shared public axis-aligned + rectangle geometry value type in logical pixels, used alongside `Point2D` and `PortSide`. +- `LayoutLabel` (node record) — `X`, `Y`, `MaxWidth`, `Text`, `Align`, `Weight`, `Style`, `FontSize`. +- `LayoutBadge` (node record) — `CentreX`, `CentreY`, `Size`, `Shape`, and `Label`. +- `LayoutBand` (node record) — `X`, `Y`, `Width`, `Height`, `Orientation`, `Label`, and `Children`. +- `LayoutLifeline` (node record) — `CentreX`, `TopY`, `BottomY`, `Label`, `HeaderWidth`, + `HeaderHeight`. +- `LayoutActivation` (node record) — `CentreX`, `TopY`, `BottomY`. +- `LayoutGrid` (node record) — `X`, `Y`, and `Rows`. +- `LayoutGridRow` (sealed record) — `IsHeader` and `Cells`. +- `LayoutGridCell` (sealed record) — `Width`, `Height`, `Text`, `Align`, `ColSpan`. + +The unit also defines the notation enumerations `BoxShape`, `PortSide`, `EndMarkerStyle`, +`LineStyle`, `TextAlign`, `FontWeight`, `FontStyle`, `BadgeShape`, and `BandOrientation`. + +`Point2D` and `Rect` are the unit's shared geometry value types. They carry no behavior of their own; +they exist so every node record and every layout algorithm expresses coordinates and bounds with one +consistent, allocation-light vocabulary. + +## Layout Tree Design Constraints + +- All node coordinates shall be absolute in logical pixels with the origin at the top-left; the model + shall not apply any coordinate transform. +- `LayoutBox` shall express nesting as an integer `Depth`, not a resolved color, so that the theme + in effect at render time selects the fill color. +- `LayoutNode` shall remain an open discriminated union; adding a new node type shall be an additive + change, and consumers shall skip node types they do not recognize. +- All node records shall be immutable, so a placed tree can be shared and rendered repeatedly without + defensive copying. + +## Layout Tree Interactions + +The Layout Tree is produced by a layout algorithm (see the *Rendering Abstractions* design, +`ILayoutAlgorithm`) and consumed by a renderer (`IRenderer`). Within the tree, `LayoutBox` and +`LayoutBand` hold nested `Children`, so renderers walk the node hierarchy recursively. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Model-LayoutTree-Canvas | `LayoutTree.Width`, `Height`, `Nodes` | +| Rendering-Model-LayoutTree-AbsoluteCoordinates | Absolute coordinates on every node record | +| Rendering-Model-LayoutTree-Box | `LayoutBox` record | +| Rendering-Model-LayoutTree-DepthNotColor | `LayoutBox.Depth` integer | +| Rendering-Model-LayoutTree-Port | `LayoutPort` record | +| Rendering-Model-LayoutTree-Line | `LayoutLine` record | +| Rendering-Model-LayoutTree-Label | `LayoutLabel` record | +| Rendering-Model-LayoutTree-Badge | `LayoutBadge` record | +| Rendering-Model-LayoutTree-Band | `LayoutBand` record | +| Rendering-Model-LayoutTree-Lifeline | `LayoutLifeline` record | +| Rendering-Model-LayoutTree-Activation | `LayoutActivation` record | +| Rendering-Model-LayoutTree-Grid | `LayoutGrid` / `LayoutGridRow` / `LayoutGridCell` | +| Rendering-Model-LayoutTree-Geometry | `Point2D` and `Rect` value types | diff --git a/docs/design/rendering/options.md b/docs/design/rendering/options.md new file mode 100644 index 0000000..e167a28 --- /dev/null +++ b/docs/design/rendering/options.md @@ -0,0 +1,65 @@ +# Options Unit Design + +Part of the Rendering Model system. + +## Options Overview + +The Options unit is the open, ELK-inspired configuration system. Configuration values are keyed by +`LayoutProperty` constants rather than by fixed fields, so new options are introduced by declaring +new keys without changing any method signature. Any object that needs to carry configuration +implements `IPropertyHolder`; the concrete `PropertyHolder` provides a dictionary-backed store keyed +by each property's identifier. + +## Options Data Model + +- `LayoutProperty` (sealed class) — `Id` and `DefaultValue`. +- `IPropertyHolder` (interface) — `Get`, `TryGet`, `Set`, `Contains`. +- `PropertyHolder` (class implementing `IPropertyHolder`) — dictionary-backed store. +- `LayoutOptions` (sealed class extending `PropertyHolder`) — with a `ForAlgorithm(string)` factory. +- `CoreOptions` (static class) — well-known keys `Algorithm`, `HierarchyHandling`, `Direction`, + `EdgeRouting`, `NodeSpacing`, `LayerSpacing`. +- `LayoutFlowDirection` (enum) — `Right`, `Left`, `Down`, `Up`. +- `HierarchyHandling` (enum) — `SeparateChildren` (the only shipped mode). Mirrors ELK's + `elk.hierarchyHandling`; selects how a hierarchical layout engine treats a container node's nested + children. Under `SeparateChildren` each container is laid out in its own coordinate space and sized + to fit its children. An `IncludeChildren` (cross-boundary) mode is a planned future additive value. + +## Options Key Methods + +`TValue Get(LayoutProperty property)` — returns the stored value for a property, or +the property's `DefaultValue` when it has not been set. Throws `ArgumentNullException` when the +property is null. + +`bool TryGet(LayoutProperty property, out TValue value)` — reports whether the +property was explicitly set; when it was not, yields the declared default through `value`. + +`IPropertyHolder Set(LayoutProperty property, TValue value)` — stores a value keyed by +the property identifier, replacing any previous value, and returns the holder for fluent chaining. + +`bool Contains(LayoutProperty property)` — reports whether the property has been +explicitly set on this holder. + +`LayoutOptions.ForAlgorithm(string algorithmId)` — creates a `LayoutOptions` pre-set with +`CoreOptions.Algorithm`. + +## Options Design Constraints + +- `PropertyHolder` shall store values keyed by `LayoutProperty.Id`, so an option that no algorithm + yet honors is carried harmlessly and simply ignored by algorithms that do not read it. +- `CoreOptions` keys marked advisory shall default harmlessly until an algorithm implements them, so + that adding a key is a purely additive change. + +## Options Interactions + +`LayoutGraph`, `LayoutGraphNode`, and `LayoutGraphEdge` (in the Layout Graph unit) all derive from +`PropertyHolder`, so configuration can be attached to the whole graph or to a single element. +`LayoutOptions` is passed to a layout algorithm alongside a `LayoutGraph`. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Model-Options-Default | `PropertyHolder.Get` default path | +| Rendering-Model-Options-StoreAndRetrieve | `PropertyHolder.Set` / `Get` | +| Rendering-Model-Options-Contains | `PropertyHolder.Contains` | +| Rendering-Model-Options-TryGet | `PropertyHolder.TryGet` | diff --git a/docs/design/rendering/rendering.md b/docs/design/rendering/rendering.md index 5c17955..a126a89 100644 --- a/docs/design/rendering/rendering.md +++ b/docs/design/rendering/rendering.md @@ -1,93 +1,69 @@ -# System Design - -This document provides the system-level design for the Rendering. - -## System Architecture - -The Rendering is a minimal .NET library template demonstrating DEMA Consulting -best practices. The system consists of: - -- **Demo Unit**: Simple greeting functionality demonstrating library patterns - -## External Interfaces - -### Dependencies - -**Runtime Dependencies:** - -- **.NET Runtime**: Provides core framework functionality - -**Build and Quality Tooling:** - -- **xUnit v3**: Unit testing framework for verification -- **ReqStream**: Requirements traceability enforcement -- **ReviewMark**: File review management -- **BuildMark/VersionMark**: Build and version documentation -- **SonarMark/SarifMark**: Quality analysis integration - -### Public API - -The system exposes: - -- **Demo()**: Default constructor; initializes the instance with the default prefix `"Hello"` -- **Demo(string prefix)**: Custom-prefix constructor; initializes the instance with the specified - prefix. Throws `ArgumentNullException` if `prefix` is null; throws `ArgumentException` if - `prefix` is an empty string. -- **Demo.Prefix**: Read-only property that returns the greeting prefix configured at construction -- **Demo.DemoMethod(string name)**: Returns a greeting string in the format `{prefix}, {name}!`. - Throws `ArgumentNullException` if `name` is null; throws `ArgumentException` if `name` is an - empty string. -- Package metadata and documentation for NuGet distribution - -## Data Flow - -**Construction path:** - -1. **Input**: Constructor parameter `prefix` (optional; defaults to `"Hello"` when using `Demo()`) -2. **Validation**: `Demo(string prefix)` rejects null with `ArgumentNullException`; rejects empty - string with `ArgumentException` -3. **Storage**: Valid prefix stored for use in subsequent greeting calls - -**Method-call path:** - -1. **Input**: Method parameter `name` (required, non-empty string) -2. **Validation**: `DemoMethod` rejects null with `ArgumentNullException`; rejects empty string - with `ArgumentException` -3. **Processing**: Simple string formatting combining stored prefix and supplied name -4. **Output**: Formatted greeting string in the format `{prefix}, {name}!` - -## System Constraints - -- **Simplicity**: Minimal functionality to serve as template -- **Compliance**: All functionality must be traceable to requirements -- **Quality**: Zero warnings, full test coverage, complete documentation -- **Portability**: Compatible across supported .NET platforms - -## Platform Support - -The library targets the following frameworks, enabling broad compatibility across modern .NET -runtimes and legacy environments: - -| Target Framework | Runtime / Environment | -|--------------------|---------------------------------------------------| -| `netstandard2.0` | .NET Standard 2.0 (implemented by .NET FX 4.8.1+) | -| `net8.0` | .NET 8 LTS | -| `net9.0` | .NET 9 | -| `net10.0` | .NET 10 | - -The library is supported on the following operating systems: - -- **Windows** — primary developer and CI platform -- **Linux** — CI/CD and containerized environments -- **macOS** — developer workstations using Apple platforms - -Portability is achieved by restricting the implementation exclusively to Base Class Library (BCL) -APIs available across all target frameworks. No platform-specific native interop, OS-specific -APIs, or framework-version-specific features are used. - -## Integration Patterns - -- **NuGet Packaging**: Standard .NET library packaging and distribution -- **CI/CD Integration**: Automated build, test, and quality validation -- **Requirements Traceability**: All features linked to passing tests -- **Review Management**: Systematic file review using ReviewMark patterns +# Rendering Model Design + +## Overview + +`DemaConsulting.Rendering` is the SysML-agnostic rendering model. It defines the data types that +flow through the rendering pipeline but contains no layout algorithm and no renderer. It provides +three things: the placed `LayoutTree` intermediate representation that a renderer draws, the open +(ELK-inspired) property-based option system that carries configuration, and the unplaced +`LayoutGraph` input that a layout algorithm consumes. The package has no runtime dependencies beyond +the .NET base class library, and its types are immutable records or small mutable holders. + +A layout algorithm consumes a `LayoutGraph` plus a `LayoutOptions` and produces a placed +`LayoutTree`; a renderer then draws that tree. This document describes the model that sits at both +ends of that flow. The algorithm and renderer contracts themselves live in the +*Rendering Abstractions* system. + +## Software Structure + +```text +DemaConsulting.Rendering (System) +├── LayoutTree (Unit) +├── Options (Unit) +└── LayoutGraph (Unit) +``` + +- **Layout Tree** — the placed intermediate representation: `LayoutTree` and the `LayoutNode` + discriminated-union hierarchy of concrete node records, plus the shared `Point2D` / `Rect` + geometry value types. Detailed in Layout Tree Unit Design. +- **Options** — the open configuration system: `LayoutProperty`, `IPropertyHolder`, + `PropertyHolder`, `LayoutOptions`, `CoreOptions`, `LayoutFlowDirection`, and `HierarchyHandling`. + Detailed in Options Unit Design. +- **Layout Graph** — the unplaced input model: `LayoutGraph`, `LayoutGraphNode`, `LayoutGraphEdge`. + Detailed in Layout Graph Unit Design. + +## System Interactions + +A `LayoutGraph` plus a `LayoutOptions` is the input consumed by an `ILayoutAlgorithm` (defined in the +*Rendering Abstractions* system); the algorithm produces a placed `LayoutTree`, which an `IRenderer` +then draws to a concrete output format. The Options unit's `PropertyHolder` is the base type for the +Layout Graph elements, so configuration flows from the input model into the algorithms uniformly. The +model itself performs no layout and no rendering; it defines only the shared vocabulary at both ends +of the pipeline. + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| Rendering-Model-LayoutTree | The `LayoutTree` and `LayoutNode` hierarchy (see Layout Tree Unit Design) | +| Rendering-Model-Configuration | The `IPropertyHolder` / `LayoutProperty` option system (see Options Unit Design) | +| Rendering-Model-InputGraph | The `LayoutGraph` input model (see Layout Graph Unit Design) | + +## Model Scope Exclusions + +The following public members are optional presentation or diagnostic metadata that the model carries +through unchanged. The +model's only obligation is to store and return them unchanged; they carry no algorithmic behavior of +their own, and the rendered behavior they influence is specified and verified in the renderer systems +(`DemaConsulting.Rendering.Svg` / `DemaConsulting.Rendering.Skia`), not in the model. They are +therefore intentionally excluded from the Rendering-Model requirement set and are not given +individual functional requirements or verification scenarios: + +| Member | Kind | Rationale | +| --- | --- | --- | +| `LayoutBox.Keyword` | presentation | Optional SysML keyword drawn above the box label; renderer pass-through. | +| `LayoutTree.Warnings` | diagnostic | Non-fatal layout-quality diagnostics; advisory only. | +| `LayoutGraphNode.Label` | input-graph presentation | Optional display text carried to the renderer. | +| `LayoutGraphEdge.Label` | input-graph presentation | Optional midpoint display text carried to the renderer. | +| `LayoutGraphEdge.TargetEnd` | input-graph presentation | Optional end-marker style hint carried to the renderer. | +| `LayoutGraphEdge.LineStyle` | input-graph presentation | Optional stroke-style hint carried to the renderer. | diff --git a/docs/gallery/containment-packed.svg b/docs/gallery/containment-packed.svg new file mode 100644 index 0000000..fe559a6 --- /dev/null +++ b/docs/gallery/containment-packed.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Core + + Model + + Layout + + Svg + + Skia + + Abstractions + + Themes + + Options + + Engine + + Registry + + Renderer + + Graph + diff --git a/docs/gallery/gallery.md b/docs/gallery/gallery.md new file mode 100644 index 0000000..92faf68 --- /dev/null +++ b/docs/gallery/gallery.md @@ -0,0 +1,62 @@ +# 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. + +Regenerate this page and its images by running `./gallery.ps1` from the repository root. + +## 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. + +![Layered pipeline diagram](layered-pipeline.svg) + +A directed pipeline laid out left to right by the layered algorithm. + +![Containment packed diagram](containment-packed.svg) + +Sibling boxes packed compactly by the containment algorithm. + +![Hierarchical nested diagram](hierarchical-nested.svg) + +A container node holding a nested child graph, with a cross-container edge. + +## 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. + +## 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. + +## 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. diff --git a/docs/gallery/hierarchical-nested.png b/docs/gallery/hierarchical-nested.png new file mode 100644 index 0000000..9b16e22 Binary files /dev/null and b/docs/gallery/hierarchical-nested.png differ diff --git a/docs/gallery/hierarchical-nested.svg b/docs/gallery/hierarchical-nested.svg new file mode 100644 index 0000000..44c802c --- /dev/null +++ b/docs/gallery/hierarchical-nested.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Service + + API + + Worker + + Store + + + + Client + + diff --git a/docs/gallery/layered-pipeline.png b/docs/gallery/layered-pipeline.png new file mode 100644 index 0000000..6c7ad90 Binary files /dev/null and b/docs/gallery/layered-pipeline.png differ diff --git a/docs/gallery/layered-pipeline.svg b/docs/gallery/layered-pipeline.svg new file mode 100644 index 0000000..9f90c95 --- /dev/null +++ b/docs/gallery/layered-pipeline.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Ingest + + Validate + + Transform + + Render + + Publish + + Report + + + + + + diff --git a/docs/gallery/orthogonal-obstacle.svg b/docs/gallery/orthogonal-obstacle.svg new file mode 100644 index 0000000..dfc4752 --- /dev/null +++ b/docs/gallery/orthogonal-obstacle.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Left + + Source + + Obstacle + + Blocker + + Right + + Sink + + diff --git a/docs/gallery/theme-dark.png b/docs/gallery/theme-dark.png new file mode 100644 index 0000000..b8655ac Binary files /dev/null and b/docs/gallery/theme-dark.png differ diff --git a/docs/gallery/theme-light.png b/docs/gallery/theme-light.png new file mode 100644 index 0000000..7485481 Binary files /dev/null and b/docs/gallery/theme-light.png differ diff --git a/docs/gallery/theme-print.png b/docs/gallery/theme-print.png new file mode 100644 index 0000000..c0aef6b Binary files /dev/null and b/docs/gallery/theme-print.png differ diff --git a/docs/reqstream/ots/buildmark.yaml b/docs/reqstream/ots/buildmark.yaml index 9e820c0..d1f62c8 100644 --- a/docs/reqstream/ots/buildmark.yaml +++ b/docs/reqstream/ots/buildmark.yaml @@ -8,7 +8,7 @@ sections: sections: - title: BuildMark Requirements requirements: - - id: Template-OTS-BuildMark + - id: Rendering-OTS-BuildMark title: BuildMark shall generate build-notes documentation from GitHub Actions metadata. justification: | DemaConsulting.BuildMark queries the GitHub API to capture workflow run details and diff --git a/docs/reqstream/ots/fileassert.yaml b/docs/reqstream/ots/fileassert.yaml index 130f6a7..ed6214e 100644 --- a/docs/reqstream/ots/fileassert.yaml +++ b/docs/reqstream/ots/fileassert.yaml @@ -8,7 +8,7 @@ sections: sections: - title: FileAssert Requirements requirements: - - id: Template-OTS-FileAssert + - id: Rendering-OTS-FileAssert title: FileAssert shall validate generated documents against acceptance criteria. justification: | DemaConsulting.FileAssert validates HTML and PDF documents produced during the diff --git a/docs/reqstream/ots/pandoc.yaml b/docs/reqstream/ots/pandoc.yaml index dd79478..2eb3e32 100644 --- a/docs/reqstream/ots/pandoc.yaml +++ b/docs/reqstream/ots/pandoc.yaml @@ -8,7 +8,7 @@ sections: sections: - title: Pandoc Requirements requirements: - - id: Template-OTS-Pandoc + - id: Rendering-OTS-Pandoc title: Pandoc shall convert Markdown documents to valid HTML. justification: | DemaConsulting.PandocTool converts Markdown source documents to HTML as part of the diff --git a/docs/reqstream/ots/reqstream.yaml b/docs/reqstream/ots/reqstream.yaml index 8de838b..c42c15a 100644 --- a/docs/reqstream/ots/reqstream.yaml +++ b/docs/reqstream/ots/reqstream.yaml @@ -8,7 +8,7 @@ sections: sections: - title: ReqStream Requirements requirements: - - id: Template-OTS-ReqStream + - id: Rendering-OTS-ReqStream title: ReqStream shall enforce that every requirement is linked to passing test evidence. justification: | DemaConsulting.ReqStream processes requirements.yaml and the TRX test-result files to @@ -23,7 +23,7 @@ sections: - ReqStream_ReportExport - ReqStream_TagsFiltering - ReqStream_EnforcementMode - - id: Template-OTS-ReqStream-Lint + - id: Rendering-OTS-ReqStream-Lint title: ReqStream shall identify and report issues in requirements files when run in lint mode. justification: | DemaConsulting.ReqStream provides a lint mode that checks requirements YAML files for diff --git a/docs/reqstream/ots/reviewmark.yaml b/docs/reqstream/ots/reviewmark.yaml index 25d9177..a186e6a 100644 --- a/docs/reqstream/ots/reviewmark.yaml +++ b/docs/reqstream/ots/reviewmark.yaml @@ -8,7 +8,7 @@ sections: sections: - title: ReviewMark Requirements requirements: - - id: Template-OTS-ReviewMark + - id: Rendering-OTS-ReviewMark title: ReviewMark shall produce review documentation from the review configuration. justification: | DemaConsulting.ReviewMark reads the .reviewmark.yaml configuration and the review @@ -19,7 +19,7 @@ sections: tests: - ReviewMark_ReviewPlanGeneration - ReviewMark_ReviewReportGeneration - - id: Template-OTS-ReviewMark-IndexScan + - id: Rendering-OTS-ReviewMark-IndexScan title: ReviewMark shall scan PDF evidence files into an index catalogue. justification: | ReviewMark's --index command scans review evidence PDFs and writes an index.json @@ -27,7 +27,7 @@ sections: tags: [ots] tests: - ReviewMark_IndexScan - - id: Template-OTS-ReviewMark-Enforce + - id: Rendering-OTS-ReviewMark-Enforce title: ReviewMark shall enforce review currency by exiting non-zero when files are not current. justification: | ReviewMark's --enforce command exits with a non-zero code when any file in a review @@ -36,7 +36,7 @@ sections: tags: [ots] tests: - ReviewMark_Enforce - - id: Template-OTS-ReviewMark-Elaborate + - id: Rendering-OTS-ReviewMark-Elaborate title: ReviewMark shall elaborate review-set details for agent and pipeline use. justification: | ReviewMark's --elaborate command prints the review-set ID, fingerprint, and file @@ -45,7 +45,7 @@ sections: tags: [ots] tests: - ReviewMark_Elaborate - - id: Template-OTS-ReviewMark-Lint + - id: Rendering-OTS-ReviewMark-Lint title: ReviewMark shall validate review definition files and report configuration issues. justification: | ReviewMark's --lint command checks the .reviewmark.yaml configuration for structural @@ -54,7 +54,7 @@ sections: tags: [ots] tests: - ReviewMark_Lint - - id: Template-OTS-ReviewMark-DirectoryOverride + - id: Rendering-OTS-ReviewMark-DirectoryOverride title: ReviewMark shall support overriding the working directory for file operations. justification: | ReviewMark's --dir flag allows callers to specify the working directory used when diff --git a/docs/reqstream/ots/sarifmark.yaml b/docs/reqstream/ots/sarifmark.yaml index be92a5a..4ca8c53 100644 --- a/docs/reqstream/ots/sarifmark.yaml +++ b/docs/reqstream/ots/sarifmark.yaml @@ -8,7 +8,7 @@ sections: sections: - title: SarifMark Requirements requirements: - - id: Template-OTS-SarifMark + - id: Rendering-OTS-SarifMark title: SarifMark shall convert CodeQL SARIF results into a markdown report. justification: | DemaConsulting.SarifMark reads the SARIF output produced by CodeQL code scanning and @@ -19,7 +19,7 @@ sections: tests: - SarifMark_SarifReading - SarifMark_MarkdownReportGeneration - - id: Template-OTS-SarifMark-Enforcement + - id: Rendering-OTS-SarifMark-Enforcement title: SarifMark shall return a non-zero exit code when SARIF issues are detected. justification: | When invoked in enforcement mode, SarifMark exits non-zero if the SARIF input diff --git a/docs/reqstream/ots/sonarmark.yaml b/docs/reqstream/ots/sonarmark.yaml index e9910d8..bb7e889 100644 --- a/docs/reqstream/ots/sonarmark.yaml +++ b/docs/reqstream/ots/sonarmark.yaml @@ -8,7 +8,7 @@ sections: sections: - title: SonarMark Requirements requirements: - - id: Template-OTS-SonarMark + - id: Rendering-OTS-SonarMark title: SonarMark shall generate a SonarCloud quality report. justification: | DemaConsulting.SonarMark retrieves quality-gate and metrics data from SonarCloud and diff --git a/docs/reqstream/ots/versionmark.yaml b/docs/reqstream/ots/versionmark.yaml index dfd9bc7..33e3f34 100644 --- a/docs/reqstream/ots/versionmark.yaml +++ b/docs/reqstream/ots/versionmark.yaml @@ -8,7 +8,7 @@ sections: sections: - title: VersionMark Requirements requirements: - - id: Template-OTS-VersionMark + - id: Rendering-OTS-VersionMark title: VersionMark shall publish captured tool-version information. justification: | DemaConsulting.VersionMark reads version metadata for each dotnet tool used in the @@ -19,7 +19,7 @@ sections: tests: - VersionMark_CapturesVersions - VersionMark_GeneratesMarkdownReport - - id: Template-OTS-VersionMark-Lint + - id: Rendering-OTS-VersionMark-Lint title: VersionMark shall validate its configuration file and report errors for invalid input. justification: | DemaConsulting.VersionMark provides a lint mode that checks the .versionmark.yaml diff --git a/docs/reqstream/ots/weasyprint.yaml b/docs/reqstream/ots/weasyprint.yaml index b8ebe6d..c3e0bcf 100644 --- a/docs/reqstream/ots/weasyprint.yaml +++ b/docs/reqstream/ots/weasyprint.yaml @@ -8,7 +8,7 @@ sections: sections: - title: WeasyPrint Requirements requirements: - - id: Template-OTS-WeasyPrint + - id: Rendering-OTS-WeasyPrint title: WeasyPrint shall convert HTML documents to valid PDF. justification: | DemaConsulting.WeasyPrintTool converts HTML documents to PDF as part of the diff --git a/docs/reqstream/ots/xunit.yaml b/docs/reqstream/ots/xunit.yaml index e98fbb3..37cb8ab 100644 --- a/docs/reqstream/ots/xunit.yaml +++ b/docs/reqstream/ots/xunit.yaml @@ -2,45 +2,49 @@ # xUnit OTS Software Requirements # # Requirements for the xUnit testing framework functionality. +# +# Unlike the DemaConsulting tool OTS items (BuildMark, ReviewMark, ...) which are verified by their +# own self-validation TRX, xUnit is the test framework itself: its OTS requirements are satisfied by +# this repository's own test methods, which xUnit discovers, executes, and records into the TRX result +# files that ReqStream traces. The linked tests below are a stable, representative sample spanning the +# model, layout, and renderer projects. sections: - title: OTS Software Requirements sections: - title: xUnit Requirements requirements: - - id: Template-OTS-xUnit-Execute + - id: Rendering-OTS-xUnit-Execute title: xUnit shall discover and execute unit tests. justification: | xUnit v3 (xunit.v3 and xunit.runner.visualstudio) is the unit-testing framework used by the project. It discovers and runs all test methods annotated with [Fact] or - [Theory]. Passing tests confirm the framework correctly identifies and executes each - test method. + [Theory]. These passing tests, drawn from across the solution, confirm the framework + correctly identifies and executes each test method. tags: [ots] tests: - - Demo_DemoMethod_DefaultPrefix_ReturnsGreeting - - Demo_DemoMethod_CustomPrefix_ReturnsGreeting - - Demo_DemoMethod_NullInput_ThrowsArgumentNullException - - Demo_DemoMethod_EmptyInput_ThrowsArgumentException - - Demo_Constructor_NullPrefix_ThrowsArgumentNullException - - Demo_Constructor_EmptyPrefix_ThrowsArgumentException - - Demo_DefaultPrefix_Read_IsHello - - Demo_Prefix_WithCustomConstruction_ReturnsCustomPrefix - - Demo_DefaultConstructor_WithNoArgs_SetsDefaultPrefix - - id: Template-OTS-xUnit-Report + - Get_AfterSet_ReturnsStoredValue + - Contains_ReflectsExplicitSet + - AddNode_AppendsNodeAndReturnsIt + - LayoutTree_Construction_StoresWidthHeightNodes + - Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges + - Id_IsLayered + - SvgRenderer_Render_SingleBox_ProducesSvgDocument + - PngRenderer_Render_SingleBox_ProducesNonEmptyOutput + - id: Rendering-OTS-xUnit-Report title: xUnit shall record test results in TRX output files. justification: | xUnit v3 with xunit.runner.visualstudio writes TRX result files that feed into - coverage reporting and requirements traceability via ReqStream. The presence of + coverage reporting and requirements traceability via ReqStream. The presence of these named test results in the TRX output confirms the reporting pipeline is functioning correctly. tags: [ots] tests: - - Demo_DemoMethod_DefaultPrefix_ReturnsGreeting - - Demo_DemoMethod_CustomPrefix_ReturnsGreeting - - Demo_DemoMethod_NullInput_ThrowsArgumentNullException - - Demo_DemoMethod_EmptyInput_ThrowsArgumentException - - Demo_Constructor_NullPrefix_ThrowsArgumentNullException - - Demo_Constructor_EmptyPrefix_ThrowsArgumentException - - Demo_DefaultPrefix_Read_IsHello - - Demo_Prefix_WithCustomConstruction_ReturnsCustomPrefix - - Demo_DefaultConstructor_WithNoArgs_SetsDefaultPrefix + - Get_AfterSet_ReturnsStoredValue + - Contains_ReflectsExplicitSet + - AddNode_AppendsNodeAndReturnsIt + - LayoutTree_Construction_StoresWidthHeightNodes + - Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges + - Id_IsLayered + - SvgRenderer_Render_SingleBox_ProducesSvgDocument + - PngRenderer_Render_SingleBox_ProducesNonEmptyOutput diff --git a/docs/reqstream/rendering-abstractions/box-metrics.yaml b/docs/reqstream/rendering-abstractions/box-metrics.yaml new file mode 100644 index 0000000..a311d07 --- /dev/null +++ b/docs/reqstream/rendering-abstractions/box-metrics.yaml @@ -0,0 +1,32 @@ +--- +# DemaConsulting.Rendering.Abstractions - Box Metrics Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the box-metrics unit: BoxMetrics title-area and folder-tab geometry formulas. +# - These unit requirements decompose the Rendering-Abstractions-SharedGeometry system requirement declared in +# rendering-abstractions.yaml. + +sections: + - title: Box Metrics Unit Requirements + requirements: + - id: Rendering-Abstractions-BoxMetrics-FolderTabHeight + title: >- + The box metrics shall compute the folder-tab height as the body font size plus two label + paddings. + justification: | + The folder tab on a package box must be tall enough for its label; deriving its height from + theme font and padding keeps the reserved tab and the drawn tab identical. + tests: + - BoxMetrics_FolderTabHeight_DerivesFromThemeBodyFontAndPadding + + - id: Rendering-Abstractions-BoxMetrics-TitleAreaHeight + title: >- + The box metrics shall compute the reserved box title-area height from the presence of a + keyword line and a name line, reserving no title area when neither is present. + justification: | + The title area holds the optional keyword line and the bold name; computing its height from + which lines are present keeps space reserved by layout equal to space drawn by renderers. + tests: + - BoxMetrics_TitleAreaHeight_NoLabelNoKeyword_IsZero + - BoxMetrics_TitleAreaHeight_LabelOnly_ReservesTitleLine + - BoxMetrics_TitleAreaHeight_LabelAndKeyword_ReservesBothLines diff --git a/docs/reqstream/rendering-abstractions/connector-label-placer.yaml b/docs/reqstream/rendering-abstractions/connector-label-placer.yaml new file mode 100644 index 0000000..cd616ca --- /dev/null +++ b/docs/reqstream/rendering-abstractions/connector-label-placer.yaml @@ -0,0 +1,40 @@ +--- +# DemaConsulting.Rendering.Abstractions - Connector Label Placer Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the connector-label-placer unit: ConnectorLabelPlacer connector +# midpoint-label placement. +# - These unit requirements decompose the Rendering-Abstractions-SharedGeometry system requirement declared in +# rendering-abstractions.yaml. + +sections: + - title: Connector Label Placer Unit Requirements + requirements: + - id: Rendering-Abstractions-ConnectorLabelPlacer-OmitUnlabelled + title: >- + The connector-label placer shall omit lines that carry no midpoint label from its result. + justification: | + Only labelled connectors need a placed position; omitting unlabelled lines keeps the result + limited to the labels a renderer must actually draw. + tests: + - Place_LineWithoutLabel_IsOmitted + + - id: Rendering-Abstractions-ConnectorLabelPlacer-LongestSegment + title: >- + The connector-label placer shall place a label at the midpoint of its line's longest + segment. + justification: | + The longest segment is the most open run between boxes; placing the label there gives it + the most clear space and the least chance of overlapping other diagram content. + tests: + - Place_SingleLine_UsesLongestSegmentMidpoint + + - id: Rendering-Abstractions-ConnectorLabelPlacer-AvoidOverlap + title: >- + The connector-label placer shall separate labels whose preferred positions would otherwise + overlap. + justification: | + Two connectors that cross would place their labels on the same point; nudging one aside + keeps both labels readable, and both renderers share the placer so their layouts match. + tests: + - Place_CollidingLabels_AreSeparated diff --git a/docs/reqstream/rendering-abstractions/notation-metrics.yaml b/docs/reqstream/rendering-abstractions/notation-metrics.yaml new file mode 100644 index 0000000..4d84377 --- /dev/null +++ b/docs/reqstream/rendering-abstractions/notation-metrics.yaml @@ -0,0 +1,77 @@ +--- +# DemaConsulting.Rendering.Abstractions - Notation Metrics Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the notation-metrics unit: NotationMetrics and MarkerVertex shared notation geometry. +# - These unit requirements decompose the Rendering-Abstractions-SharedGeometry system requirement declared in +# rendering-abstractions.yaml. + +sections: + - title: Notation Metrics Unit Requirements + requirements: + - id: Rendering-Abstractions-NotationMetrics-TriangleGeometry + title: >- + The notation metrics shall define the triangular end-marker geometry with the canonical + marker dimensions and an apex vertex that overshoots the line endpoint. + justification: | + The open chevron, hollow triangle, and filled arrow all share one triangle geometry; + defining it once ensures every renderer draws these markers identically. + tests: + - TriangleFamily_HasCanonicalValues + - TriangleVertices_ReproduceSvgBoxPoints + - TriangleVertices_Apex_OvershootsEndpoint + + - id: Rendering-Abstractions-NotationMetrics-DiamondGeometry + title: >- + The notation metrics shall define the diamond end-marker geometry with the canonical + dimensions and a far vertex that lands on the line endpoint. + justification: | + The hollow and filled diamonds share one geometry whose far point must sit on the endpoint; + defining it once keeps the two renderers' diamonds identical and correctly anchored. + tests: + - Diamond_HasCanonicalValues + - DiamondVertices_ReproduceSvgBoxPoints + - DiamondVertices_FarPoint_LandsOnEndpoint + + - id: Rendering-Abstractions-NotationMetrics-CircleBarGeometry + title: >- + The notation metrics shall define the canonical circle and bar end-marker geometry. + justification: | + Circle and bar markers complete the end-decoration set; defining their canonical + dimensions once keeps them consistent across renderers. + tests: + - CircleAndBar_HaveCanonicalValues + + - id: Rendering-Abstractions-NotationMetrics-Crossbar + title: >- + The notation metrics shall position the redefinition crossbar at the documented fraction of + the end-marker length. + justification: | + The crossbar variant marks redefinition; deriving its position from a single fraction keeps + it aligned with the triangle it crosses in every renderer. + tests: + - Crossbar_IsDerivedFraction + + - id: Rendering-Abstractions-NotationMetrics-AlongLineLength + title: >- + The notation metrics shall report the along-line length consumed by each end-marker style. + justification: | + Layout strategies reserve a clean straight approach and renderers clamp the final corner + radius using this length, so both must read it from one place to stay in agreement. + tests: + - AlongLineLength_MatchesMarkerBox + + - id: Rendering-Abstractions-NotationMetrics-BoxDecorations + title: >- + The notation metrics shall define the port square, folder-tab, note-fold, rounded-rectangle + corner, badge, and label-background proportions used to draw box decorations. + justification: | + These intrinsic, theme-independent proportions describe SysML box notation; defining each + once ensures the SVG and PNG renderers draw the same shapes at the same sizes. + tests: + - Port_SizeIsTwiceHalfSize + - FolderTab_HasDocumentedConstants + - NoteFold_HasDocumentedConstants + - RoundedRectRadius_IsThemeRadiusTimesFactor + - BadgeFractions_HaveDocumentedValues + - LabelBackground_ExtentMatchesInset diff --git a/docs/reqstream/rendering-abstractions/registries.yaml b/docs/reqstream/rendering-abstractions/registries.yaml new file mode 100644 index 0000000..299b3cf --- /dev/null +++ b/docs/reqstream/rendering-abstractions/registries.yaml @@ -0,0 +1,51 @@ +--- +# DemaConsulting.Rendering.Abstractions - Registries Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the registries unit: LayoutAlgorithmRegistry and RendererRegistry. +# - These unit requirements decompose the Rendering-Abstractions-Extensibility system requirement declared in +# rendering-abstractions.yaml. + +sections: + - title: Registries Unit Requirements + requirements: + - id: Rendering-Abstractions-Registries-ResolveAlgorithm + title: >- + The algorithm registry shall register a layout algorithm under its identifier and resolve + that algorithm back by the same identifier. + justification: | + A registry lets an application assemble the set of algorithms it wishes to offer and then + resolve the requested one at layout time by the identifier carried in the options. + tests: + - LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm + + - id: Rendering-Abstractions-Registries-ResolveRenderer + title: >- + The renderer registry shall register a renderer under its media type and resolve that + renderer back by the same media type. + justification: | + A registry lets an application assemble the set of output formats it wishes to offer and + then resolve the requested renderer at render time by media type. + tests: + - RendererRegistry_RegisterThenResolve_ReturnsRenderer + + - id: Rendering-Abstractions-Registries-ResolveRendererByExtension + title: >- + The renderer registry shall also index each renderer by every file extension it produces + and resolve a renderer from an output file extension, ignoring case and an optional leading + dot. + justification: | + Selecting a renderer from an output filename (for example diagram.webp) is a common need + for command-line and file-driven callers; matching case-insensitively and with an optional + leading dot makes the lookup forgiving of caller input. + tests: + - RendererRegistry_ResolveByExtension_MatchesAdvertisedExtensions + + - id: Rendering-Abstractions-Registries-MissingThrows + title: >- + Resolving an identifier that has not been registered shall raise a not-found error. + justification: | + Failing fast when a requested algorithm or renderer is absent surfaces a configuration + mistake immediately rather than returning a null that fails obscurely later. + tests: + - LayoutAlgorithmRegistry_ResolveMissing_Throws diff --git a/docs/reqstream/rendering-abstractions/rendering-abstractions.yaml b/docs/reqstream/rendering-abstractions/rendering-abstractions.yaml new file mode 100644 index 0000000..396e7fd --- /dev/null +++ b/docs/reqstream/rendering-abstractions/rendering-abstractions.yaml @@ -0,0 +1,77 @@ +--- +# DemaConsulting.Rendering.Abstractions System Requirements +# +# PURPOSE: +# - Define the SYSTEM-level requirements for the DemaConsulting.Rendering.Abstractions system: the +# service provider interface (SPI) that sits between the rendering model and the concrete layout +# and renderer implementations. +# - Each system requirement decomposes downward (children) into unit requirements in the per-unit +# sibling files: +# - rendering-contracts.yaml (ILayoutAlgorithm, IRenderer, RenderOptions, RenderOutput) +# - registries.yaml (LayoutAlgorithmRegistry, RendererRegistry) +# - theme.yaml (Theme and built-in Themes) +# - notation-metrics.yaml (shared notation geometry) +# - box-metrics.yaml (box title-area and folder-tab geometry) +# - connector-label-placer.yaml (connector midpoint-label placement) + +sections: + - title: Rendering Abstractions System Requirements + requirements: + - id: Rendering-Abstractions-Extensibility + title: >- + The abstractions system shall define the pluggable layout-algorithm and renderer contracts + and shall provide registries that select an implementation by identifier or media type. + justification: | + An ELK-inspired provider model lets additional layout families and output formats be + introduced purely additively - a new implementation is written and registered, and no + existing contract changes - so consumers resolve the implementation they need at run time. + children: + - Rendering-Abstractions-Contracts-Algorithm + - Rendering-Abstractions-Contracts-Renderer + - Rendering-Abstractions-Registries-ResolveAlgorithm + - Rendering-Abstractions-Registries-ResolveRenderer + - Rendering-Abstractions-Registries-ResolveRendererByExtension + - Rendering-Abstractions-Registries-MissingThrows + tests: + - LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm + - RendererRegistry_RegisterThenResolve_ReturnsRenderer + + - id: Rendering-Abstractions-Theming + title: >- + The abstractions system shall define a visual theme model with built-in themes and derived + connector approach geometry. + justification: | + Centralizing colors, fonts, and line metrics in a theme lets a single layout be rendered + in different visual styles, and deriving the connector approach zone from theme values + keeps the space reserved by layout consistent with the space drawn by renderers. + children: + - Rendering-Abstractions-Theme-ApproachZone + - Rendering-Abstractions-Theme-BuiltInGeometry + tests: + - ConnectorApproachZone_SumsStubBendAndClearance + - Themes_HaveExpectedConnectorGeometry + + - id: Rendering-Abstractions-SharedGeometry + title: >- + The abstractions system shall provide single-source notation, box, and connector-label + geometry so that every renderer draws identical decorations and reserves consistent space. + justification: | + When the SVG and PNG renderers and the layout strategies all read one source of geometry, + their markers, box title areas, and label placements match exactly and a geometry literal + never has to be duplicated across the rendering path. + children: + - Rendering-Abstractions-NotationMetrics-TriangleGeometry + - Rendering-Abstractions-NotationMetrics-DiamondGeometry + - Rendering-Abstractions-NotationMetrics-CircleBarGeometry + - Rendering-Abstractions-NotationMetrics-Crossbar + - Rendering-Abstractions-NotationMetrics-AlongLineLength + - Rendering-Abstractions-NotationMetrics-BoxDecorations + - Rendering-Abstractions-BoxMetrics-FolderTabHeight + - Rendering-Abstractions-BoxMetrics-TitleAreaHeight + - Rendering-Abstractions-ConnectorLabelPlacer-OmitUnlabelled + - Rendering-Abstractions-ConnectorLabelPlacer-LongestSegment + - Rendering-Abstractions-ConnectorLabelPlacer-AvoidOverlap + tests: + - TriangleFamily_HasCanonicalValues + - BoxMetrics_FolderTabHeight_DerivesFromThemeBodyFontAndPadding + - Place_SingleLine_UsesLongestSegmentMidpoint diff --git a/docs/reqstream/rendering-abstractions/rendering-contracts.yaml b/docs/reqstream/rendering-abstractions/rendering-contracts.yaml new file mode 100644 index 0000000..ad42086 --- /dev/null +++ b/docs/reqstream/rendering-abstractions/rendering-contracts.yaml @@ -0,0 +1,34 @@ +--- +# DemaConsulting.Rendering.Abstractions - Rendering Contracts Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the rendering-contracts unit: ILayoutAlgorithm, IRenderer, +# RenderOptions, and RenderOutput. +# - These unit requirements decompose the Rendering-Abstractions-Extensibility system requirement declared in +# rendering-abstractions.yaml. + +sections: + - title: Rendering Contracts Unit Requirements + requirements: + - id: Rendering-Abstractions-Contracts-Algorithm + title: >- + A layout algorithm shall expose a stable identifier used to select it and shall produce a + placed layout tree from an unplaced input graph and its options. + justification: | + A stable identifier lets a caller request a specific algorithm by name, and a uniform + apply operation lets any diagram family be laid out through the same contract. + tests: + - LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm + + - id: Rendering-Abstractions-Contracts-Renderer + title: >- + A renderer shall expose its media type, its default file extension, and the full set of + file extensions it produces, and shall write the rendered output for a layout tree to a + supplied stream. + justification: | + Exposing the media type and the file extensions lets callers name and serve the output + correctly and resolve a renderer from an output filename, and writing to a caller-supplied + stream keeps the renderer free of filesystem concerns. + tests: + - RendererRegistry_RegisterThenResolve_ReturnsRenderer + - PngRenderer_FileExtensions_ContainsDefault diff --git a/docs/reqstream/rendering-abstractions/theme.yaml b/docs/reqstream/rendering-abstractions/theme.yaml new file mode 100644 index 0000000..3e3cf1a --- /dev/null +++ b/docs/reqstream/rendering-abstractions/theme.yaml @@ -0,0 +1,30 @@ +--- +# DemaConsulting.Rendering.Abstractions - Theme Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the theme unit: the Theme record and built-in Themes. +# - These unit requirements decompose the Rendering-Abstractions-Theming system requirement declared in +# rendering-abstractions.yaml. + +sections: + - title: Theme Unit Requirements + requirements: + - id: Rendering-Abstractions-Theme-ApproachZone + title: >- + The theme shall compute a connector approach zone as the sum of the perpendicular stub, the + corner bend radius, and the caller-supplied connector clearance. + justification: | + The approach zone is the clear distance a connector needs off a box face before it can + bend; deriving it from theme values keeps reserved and drawn geometry in agreement. + tests: + - ConnectorApproachZone_SumsStubBendAndClearance + + - id: Rendering-Abstractions-Theme-BuiltInGeometry + title: >- + The built-in themes shall provide the documented connector geometry, with the light and + dark themes sharing values and the print theme using tighter values. + justification: | + Ready-made themes give callers correct defaults for screen and print output without having + to specify every metric, while a tighter print theme suits monochrome output. + tests: + - Themes_HaveExpectedConnectorGeometry diff --git a/docs/reqstream/rendering-layout/connector-router.yaml b/docs/reqstream/rendering-layout/connector-router.yaml new file mode 100644 index 0000000..5b16a80 --- /dev/null +++ b/docs/reqstream/rendering-layout/connector-router.yaml @@ -0,0 +1,76 @@ +--- +# DemaConsulting.Rendering.Layout - ConnectorRouter Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the connector-router unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: ConnectorRouter Unit Requirements + requirements: + - id: Rendering-Layout-ConnectorRouter-AnchorsFaceEachOther + title: >- + ConnectorRouter shall anchor each connector on the box side whose outward normal best points + at the opposing box, so connectors leave and arrive on the faces the two boxes present to + each other. + justification: | + A connector reads as joining two boxes only when it leaves and enters the faces that point + at one another; anchoring toward the opposing box centre produces that presentation + regardless of the boxes' relative positions. + tests: + - Route_TargetToTheRight_AnchorsFaceEachOther + - Route_TargetBelow_AnchorsFaceEachOther + + - id: Rendering-Layout-ConnectorRouter-AvoidsObstacles + title: >- + ConnectorRouter shall route a connector around an intervening box without passing through + that box's interior, using axis-aligned segments. + justification: | + Connectors that cut through unrelated boxes are unreadable; the orchestration must steer the + route around boxes that lie between the two endpoints while keeping the path orthogonal. + tests: + - Route_ObstacleBetweenEndpoints_RoutesAroundInterior + + - id: Rendering-Layout-ConnectorRouter-ExcludesEndpoints + title: >- + ConnectorRouter shall exclude a connection's two endpoint boxes, matched by instance + identity, from the obstacle set used to route that connection. + justification: | + A connector must be free to leave and enter the boxes it joins; treating the endpoint boxes + as obstacles would block the connector's own anchors. Endpoints are matched by reference so + no domain concept enters the routing. + tests: + - Route_EndpointBoxes_AreExcludedFromObstacles + + - id: Rendering-Layout-ConnectorRouter-CarriesStyling + title: >- + ConnectorRouter shall carry a connection's requested target end marker, line style, and + label onto the produced connector line and shall leave the source end unmarked. + justification: | + The routing orchestration owns only geometry; the caller's styling choices must survive onto + the placed line so renderers draw the intended arrowhead, stroke, and label. + tests: + - Route_Connection_CarriesRequestedStyling + + - id: Rendering-Layout-ConnectorRouter-BatchOrder + title: >- + Given a list of connections, ConnectorRouter shall return one routed connector line per + connection in the input order. + justification: | + Callers correlate produced lines with their connections positionally; a stable one-to-one, + in-order result keeps that correlation unambiguous. + tests: + - Route_MultipleConnections_ReturnsOneLinePerConnectionInOrder + + - id: Rendering-Layout-ConnectorRouter-Validation + title: >- + ConnectorRouter shall reject a null box list, connection list, connection, or options + argument with an argument-null error. + justification: | + Failing fast on null arguments prevents undefined behavior and surfaces caller errors at the + public routing boundary. + tests: + - Route_NullBoxes_Throws + - Route_NullConnections_Throws + - Route_NullOptions_Throws + - Route_NullConnection_Throws diff --git a/docs/reqstream/rendering-layout/containment-layout-algorithm.yaml b/docs/reqstream/rendering-layout/containment-layout-algorithm.yaml new file mode 100644 index 0000000..06c31bc --- /dev/null +++ b/docs/reqstream/rendering-layout/containment-layout-algorithm.yaml @@ -0,0 +1,80 @@ +--- +# DemaConsulting.Rendering.Layout - ContainmentLayoutAlgorithm Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the containment-layout-algorithm unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: ContainmentLayoutAlgorithm Unit Requirements + requirements: + - id: Rendering-Layout-ContainmentAlgorithm-Identity + title: >- + ContainmentLayoutAlgorithm 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 containment algorithm be resolved by name alongside the layered algorithm. + tests: + - Id_IsContainment + + - id: Rendering-Layout-ContainmentAlgorithm-PacksNodes + title: >- + ContainmentLayoutAlgorithm shall place one box per top-level node, in input order and without + overlap, entirely within the canvas it reports. + justification: | + The algorithm's core arrangement is order-preserving row packing; every node must become a + placed box that renders inside the reported canvas and does not obscure any other box. + tests: + - Apply_Graph_PacksNodesNonOverlappingInInputOrderWithinCanvas + + - id: Rendering-Layout-ContainmentAlgorithm-RoutesEdges + title: >- + ContainmentLayoutAlgorithm shall emit one routed connector per input edge, carrying that + edge's target end marker, line style, and label. + justification: | + A diagram's edges must be drawn as well as its nodes; each edge becomes a routed connector + that preserves its requested styling so renderers reproduce the intended notation. + tests: + - Apply_Graph_RoutesOneConnectorPerEdgeCarryingStyling + + - id: Rendering-Layout-ContainmentAlgorithm-RoutesAroundObstacle + title: >- + ContainmentLayoutAlgorithm shall route an edge whose endpoints are separated by an intervening + packed box around that box rather than through its interior. + justification: | + The value of routing edges among packed nodes is obstacle avoidance; an edge crossing an + intervening box would be ambiguous, so the connector must steer around the box. + tests: + - Apply_EdgeCrossingInterveningBox_RoutesAroundIt + + - id: Rendering-Layout-ContainmentAlgorithm-EmptyGraph + title: >- + When given a graph with no nodes, ContainmentLayoutAlgorithm shall return an empty placed + layout tree with a positive-size canvas. + justification: | + An empty graph is a valid degenerate case that must yield a well-formed empty canvas rather + than an error or a zero-size result. + tests: + - Apply_EmptyGraph_ReturnsEmptyCanvas + + - id: Rendering-Layout-ContainmentAlgorithm-SkipsOutOfGraphEdges + title: >- + ContainmentLayoutAlgorithm shall skip any edge whose source or target is not a top-level node + of the graph. + justification: | + Edges may reference descendant nodes that this flat algorithm does not place; skipping such + out-of-graph endpoints keeps routing well-defined and mirrors the layered algorithm's handling. + tests: + - Apply_EdgeReferencingOutOfGraphNode_IsSkipped + + - id: Rendering-Layout-ContainmentAlgorithm-Validation + title: >- + ContainmentLayoutAlgorithm 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 algorithm boundary. + tests: + - Apply_NullGraph_Throws + - Apply_NullOptions_Throws diff --git a/docs/reqstream/rendering-layout/containment-layout.yaml b/docs/reqstream/rendering-layout/containment-layout.yaml new file mode 100644 index 0000000..f936648 --- /dev/null +++ b/docs/reqstream/rendering-layout/containment-layout.yaml @@ -0,0 +1,102 @@ +--- +# DemaConsulting.Rendering.Layout - ContainmentLayout Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the containment-layout unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: ContainmentLayout Unit Requirements + requirements: + - id: Rendering-Layout-ContainmentLayout-Order + title: >- + ContainmentLayout shall return the packed child boxes in their input order, positioned left to + right along each row. + justification: | + Callers rely on stable, order-preserving packing so a box's position in the input predicts its + reading position in the container; the repositioned children must appear in the same order they + were supplied. + tests: + - Pack_ItemsFitInRow_PreservesOrderLeftToRight + + - id: Rendering-Layout-ContainmentLayout-NoOverlap + title: >- + ContainmentLayout shall position packed child boxes so that no two of them overlap. + justification: | + Overlapping children hide one another. Whatever mix of sizes is supplied, every packed box must + be disjoint from every other. + tests: + - Pack_MixedSizes_ProducesNoOverlaps + + - id: Rendering-Layout-ContainmentLayout-WithinRegion + title: >- + ContainmentLayout shall place every packed child box within the bounds of the region size it + reports. + justification: | + Callers size the container from the reported region width and height. Every packed box must lie + inside those bounds so nothing is clipped or drawn outside the container. + tests: + - Pack_MixedSizes_AllChildrenWithinRegion + + - id: Rendering-Layout-ContainmentLayout-Wrapping + title: >- + ContainmentLayout shall wrap a child that would exceed the maximum content width onto a new row + positioned below the current row. + justification: | + A row may not grow past the width budget. When the next child would overflow it, the operation + must start a new row beneath the current one so the content stays within the allotted width. + tests: + - Pack_ChildExceedsWidth_WrapsToNewRow + + - id: Rendering-Layout-ContainmentLayout-OversizedChild + title: >- + ContainmentLayout shall place a child wider than the maximum content width alone on its own row + and shall widen the region to contain it. + justification: | + A child that cannot fit the width budget still has to be placed. It must occupy its own row, and + the reported region must grow so the oversized child remains fully contained. + tests: + - Pack_OversizedChild_PlacedAloneAndRegionWidens + + - id: Rendering-Layout-ContainmentLayout-EmptyInput + title: >- + When given no children, ContainmentLayout shall return no children and a region consisting + solely of the requested padding. + justification: | + An empty input is a valid degenerate case. The operation must return a well-formed, empty result + sized only by the padding rather than failing. + tests: + - Pack_EmptyInput_ReturnsPaddingOnlyRegion + + - id: Rendering-Layout-ContainmentLayout-PreservesFields + title: >- + ContainmentLayout shall carry every non-position field of each child (label, depth, shape, + compartments, nested children, and keyword) unchanged onto the repositioned box, updating only + its X and Y coordinates. + justification: | + The operation adjusts placement only; a child's identity and content must survive so the packed + boxes render exactly as supplied, just relocated to their packed coordinates. + tests: + - Pack_PreservesNonPositionFields + + - id: Rendering-Layout-ContainmentLayout-Defaults + title: >- + The containment options shall default the horizontal gap and vertical gap to eight logical + pixels and the padding to twelve logical pixels, so a caller need only supply the content width. + justification: | + Sensible spacing defaults let a caller pack boxes with a single required parameter while still + allowing each gap and the padding to be overridden to reproduce a specific container spacing. + tests: + - ContainmentOptions_Defaults_AreSensibleGapsAndPadding + + - id: Rendering-Layout-ContainmentLayout-Validation + title: >- + ContainmentLayout shall reject a null children list, a null options argument, or a null child + element with an argument-null error. + justification: | + Failing fast on null arguments prevents undefined behavior and surfaces caller errors at the + public packing boundary. + tests: + - Pack_NullChildren_Throws + - Pack_NullOptions_Throws + - Pack_NullChildElement_Throws diff --git a/docs/reqstream/rendering-layout/default-layout.yaml b/docs/reqstream/rendering-layout/default-layout.yaml new file mode 100644 index 0000000..4225d8d --- /dev/null +++ b/docs/reqstream/rendering-layout/default-layout.yaml @@ -0,0 +1,96 @@ +--- +# DemaConsulting.Rendering.Layout - DefaultLayout Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the default-layout unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: DefaultLayout Unit Requirements + requirements: + - 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. + 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 + algorithm without affecting any other caller. + tests: + - CreateDefaultRegistry_ResolvesLayeredAlgorithm + - CreateDefaultRegistry_ResolvesContainmentAlgorithm + - CreateDefaultRegistry_ResolvesHierarchicalAlgorithm + - CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms + - CreateDefaultRegistry_ReturnsIndependentInstances + + - id: Rendering-Layout-LayoutEngine-DefaultAlgorithm + title: >- + When neither the graph nor the options declares an algorithm, the layout facade shall lay the + graph out with the hierarchical engine. + justification: | + Defaulting to the hierarchical engine lets the single facade lay out both flat and nested + graphs correctly; the choice is safe because the engine is byte-identical to the leaf algorithm + for a flat graph, so no existing output changes. + tests: + - DefaultAlgorithmId_IsHierarchical + - Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly + + - id: Rendering-Layout-LayoutEngine-Resolution + title: >- + The layout facade shall resolve the algorithm from an explicit declaration on the graph in + preference to one on the options, applying the resolved algorithm to the graph. + justification: | + Layout options are naturally attached to the graph in the ELK-style model, so a graph-level + declaration must take precedence over a free-standing options declaration, giving callers a + predictable single source of truth for algorithm selection. + tests: + - Layout_OptionsDeclareLayered_MatchesLayeredAlgorithmExactly + - Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly + - Layout_GraphDeclarationOverridesOptions + + - id: Rendering-Layout-LayoutEngine-FlatEquivalence + title: >- + For a flat graph, the layout facade shall produce a placed layout tree identical to the + declared (or defaulted) leaf algorithm applied to the same graph. + justification: | + The facade is purely additive: a flat graph must lay out exactly as it would through the leaf + algorithm directly, so routing every call through the facade can never change existing output. + tests: + - Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly + - Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly + + - id: Rendering-Layout-LayoutEngine-NestedComposition + title: >- + For a nested graph laid out with the default hierarchical engine, the layout facade shall + produce a composed tree in which each container box carries its recursively laid-out children. + justification: | + The whole point of defaulting to the hierarchical engine is that a nested graph is composed + correctly from one call; the container box must hold its placed children rather than treating + the nesting as opaque. + tests: + - Layout_NestedGraphNoAlgorithmDeclared_ProducesComposedTree + + - id: Rendering-Layout-LayoutEngine-CustomRegistry + title: >- + The layout facade shall resolve the declared algorithm from a caller-supplied registry when one + is provided, and surface a lookup error when the declared algorithm is not registered. + justification: | + Callers that register custom algorithms must be able to lay out through the same facade; + resolving against their registry and failing clearly on an unknown identifier keeps algorithm + selection open and its errors diagnosable. + tests: + - Layout_CustomRegistry_ResolvesRegisteredAlgorithm + - Layout_UnregisteredAlgorithm_Throws + + - id: Rendering-Layout-LayoutEngine-Validation + title: >- + The layout facade shall reject a null graph, null options, or null registry argument with an + argument-null error. + justification: | + Failing fast on null arguments prevents undefined behavior and surfaces caller errors at the + public facade boundary. + tests: + - Layout_NullGraph_Throws + - Layout_NullOptions_Throws + - Layout_NullRegistry_Throws diff --git a/docs/reqstream/rendering-layout/edge-routing-option.yaml b/docs/reqstream/rendering-layout/edge-routing-option.yaml new file mode 100644 index 0000000..b0a9c41 --- /dev/null +++ b/docs/reqstream/rendering-layout/edge-routing-option.yaml @@ -0,0 +1,35 @@ +--- +# DemaConsulting.Rendering.Layout - EdgeRouting Option Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the edge-routing-option behavior. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: EdgeRouting Option Unit Requirements + requirements: + - id: Rendering-Layout-EdgeRouting-Selection + title: >- + The connector routing style shall be selectable per scope through the + rendering.edgerouting property key, which shall default to orthogonal routing when unset. + justification: | + Mirroring ELK's elk.edgeRouting, routing style rides the same open property system that + selects the algorithm, so any scope (graph, node, edge, or a free-standing options object) + can choose a style. An unset scope must fall back to the orthogonal default so existing + callers route orthogonally without opting in. + tests: + - CoreOptions_EdgeRouting_DefaultsToOrthogonal + - CoreOptions_EdgeRouting_HasStableId + - CoreOptions_EdgeRouting_SelectablePerScope + - CoreOptions_EdgeRouting_UnsetReturnsDefault + + - id: Rendering-Layout-EdgeRouting-Defaults + title: >- + The connector route options shall default to orthogonal routing with a caller-overridable + clearance of twelve logical pixels. + justification: | + A caller that supplies no routing preference must still get a sensible route; defaulting to + orthogonal routing with a fixed clearance provides that, while allowing the clearance to be + overridden so a downstream adapter can reproduce a specific spacing. + tests: + - ConnectorRouteOptions_Defaults_AreOrthogonalWithTwelvePixelClearance diff --git a/docs/reqstream/rendering-layout/engine/containment-packer.yaml b/docs/reqstream/rendering-layout/engine/containment-packer.yaml new file mode 100644 index 0000000..ed62349 --- /dev/null +++ b/docs/reqstream/rendering-layout/engine/containment-packer.yaml @@ -0,0 +1,79 @@ +--- +# DemaConsulting.Rendering.Layout - ContainmentPacker Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the containment-packer unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: ContainmentPacker Unit Requirements + requirements: + - id: Rendering-Layout-ContainmentPacker-SingleRow + title: >- + ContainmentPacker shall place items that together fit within the maximum content width on + a single row. + justification: | + When the available width can hold all the items, splitting them across rows would waste + vertical space. Items that fit must share one row, ordered left to right. + tests: + - Pack_ItemsFitInRow_ShareSameRow + + - id: Rendering-Layout-ContainmentPacker-Wrapping + title: >- + ContainmentPacker shall wrap an item that would exceed the maximum content width onto a + new row positioned below the current row. + justification: | + A row may not grow past the width budget. When the next item would overflow it, the packer + must start a new row beneath the current one so the content stays within the allotted width. + tests: + - Pack_ItemsExceedWidth_WrapToNewRow + + - id: Rendering-Layout-ContainmentPacker-NoOverlap + title: >- + ContainmentPacker shall position items so that no two packed rectangles overlap. + justification: | + Overlapping items hide one another. Whatever mix of sizes is supplied, every packed + rectangle must be disjoint from every other. + tests: + - Pack_MixedSizes_ProducesNoOverlaps + + - id: Rendering-Layout-ContainmentPacker-WithinBounds + title: >- + ContainmentPacker shall place every packed rectangle within the bounds of the region size + it reports. + justification: | + Callers size the container from the reported region width and height. Every packed + rectangle must lie inside those bounds so that nothing is clipped or drawn outside the + container. + tests: + - Pack_MixedSizes_AllRectsWithinBounds + + - id: Rendering-Layout-ContainmentPacker-OversizedItem + title: >- + ContainmentPacker shall place an item wider than the maximum content width alone on its + own row and shall widen the region to contain it. + justification: | + An item that cannot fit the width budget still has to be placed. It must occupy its own + row, and the reported region must grow so the oversized item remains fully contained. + tests: + - Pack_ItemWiderThanContentWidth_PlacedAloneAndRegionWidens + + - id: Rendering-Layout-ContainmentPacker-EmptyInput + title: >- + When given no items, ContainmentPacker shall return an empty result whose region consists + solely of the requested padding. + justification: | + An empty input is a valid degenerate case. The packer must return a well-formed, empty + result sized only by the padding rather than failing. + tests: + - Pack_EmptyList_ReturnsPaddingOnlyRegion + + - id: Rendering-Layout-ContainmentPacker-SingleItem + title: >- + When given a single item, ContainmentPacker shall place it at the padding origin and size + the region to that item plus padding. + justification: | + A lone item has a predictable home: the padding origin, with the region sized to wrap it + exactly plus the requested padding on each side. + tests: + - Pack_SingleItem_PositionsAtPaddingOrigin diff --git a/docs/reqstream/rendering-layout/engine/engine.yaml b/docs/reqstream/rendering-layout/engine/engine.yaml new file mode 100644 index 0000000..b0ae6c8 --- /dev/null +++ b/docs/reqstream/rendering-layout/engine/engine.yaml @@ -0,0 +1,65 @@ +--- +# DemaConsulting.Rendering.Layout - Engine Subsystem Requirements +# +# PURPOSE: +# - Subsystem-level requirements for the reusable geometric engine subsystem. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: Engine Subsystem Requirements + requirements: + - id: Rendering-Layout-OrthogonalRouting + title: >- + The Rendering.Layout engine subsystem shall route individual orthogonal connectors between + two anchors, steering around obstacles by a caller-specified clearance. + justification: | + Connector quality throughout every produced diagram flows through a single reusable + orthogonal router, so its obstacle-avoidance and clearance behavior is an engine-subsystem + capability in its own right. + children: + - Rendering-Layout-OrthogonalEdgeRouter-Orthogonal + - Rendering-Layout-OrthogonalEdgeRouter-AvoidObstacles + - Rendering-Layout-OrthogonalEdgeRouter-Clearance + - Rendering-Layout-OrthogonalEdgeRouter-PerpendicularEnds + - Rendering-Layout-OrthogonalEdgeRouter-CrossingStatus + - Rendering-Layout-OrthogonalEdgeRouter-CostBands + tests: + - Route_NoObstacles_ProducesOrthogonalPath + - Route_ObstacleBetween_RoutesAround + + - id: Rendering-Layout-Containment + title: >- + The Rendering.Layout engine subsystem shall pack a sequence of variable-size items into + rows within a width budget without overlap. + justification: | + Containment packing lays out child elements inside a containing box; providing it as a + reusable engine keeps container sizing consistent across every view that nests elements. + children: + - Rendering-Layout-ContainmentPacker-SingleRow + - Rendering-Layout-ContainmentPacker-Wrapping + - Rendering-Layout-ContainmentPacker-NoOverlap + - Rendering-Layout-ContainmentPacker-WithinBounds + - Rendering-Layout-ContainmentPacker-OversizedItem + - Rendering-Layout-ContainmentPacker-EmptyInput + - Rendering-Layout-ContainmentPacker-SingleItem + tests: + - Pack_MixedSizes_ProducesNoOverlaps + - Pack_ItemsFitInRow_ShareSameRow + + - id: Rendering-Layout-Interconnection + title: >- + The Rendering.Layout engine subsystem shall place directed graphs using Sugiyama-style + layered layout and return one orthogonal connector route per input edge. + justification: | + Layered placement with per-edge routing is the geometric contract the public algorithm and + the interconnection view depend on; exposing it as a stable facade lets both consume the + same behavior-preserving engine. + children: + - Rendering-Layout-InterconnectionEngine-Layering + - Rendering-Layout-InterconnectionEngine-NonOverlapping + - Rendering-Layout-InterconnectionEngine-DummyNodes + - Rendering-Layout-InterconnectionEngine-Waypoints + - Rendering-Layout-InterconnectionEngine-Deterministic + tests: + - Place_LinearChain_MonotonicLayerAssignment + - Place_WorkstationTopology_CorrectLayersAndNoOverlap diff --git a/docs/reqstream/rendering-layout/engine/interconnection-layout-engine.yaml b/docs/reqstream/rendering-layout/engine/interconnection-layout-engine.yaml new file mode 100644 index 0000000..b9afd55 --- /dev/null +++ b/docs/reqstream/rendering-layout/engine/interconnection-layout-engine.yaml @@ -0,0 +1,64 @@ +--- +# DemaConsulting.Rendering.Layout - InterconnectionLayoutEngine Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the interconnection-layout-engine unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: InterconnectionLayoutEngine Unit Requirements + requirements: + - id: Rendering-Layout-InterconnectionEngine-Layering + title: >- + InterconnectionLayoutEngine shall assign layer indices using longest-path layering so that + every connected pair of nodes occupies different layers. + justification: | + Longest-path layering guarantees no same-layer connections, eliminating the connector + clipping that occurred with the earlier breadth-first approach. + tests: + - Place_LinearChain_MonotonicLayerAssignment + - Place_WorkstationTopology_CorrectLayersAndNoOverlap + + - id: Rendering-Layout-InterconnectionEngine-NonOverlapping + title: >- + InterconnectionLayoutEngine shall produce node rectangles that do not overlap. + justification: | + Overlapping boxes prevent readable diagrams; the layered column structure and per-column + vertical stacking guarantee non-overlap by construction. + tests: + - Place_WorkstationTopology_CorrectLayersAndNoOverlap + + - id: Rendering-Layout-InterconnectionEngine-DummyNodes + title: >- + InterconnectionLayoutEngine shall insert dummy nodes for long edges (span greater than + one) and exclude them from the returned rectangles so the rect count equals the input node + count. + justification: | + Dummy nodes force routing through the correct inter-layer corridors, preventing connector + lines from clipping intermediate boxes. They must not appear as layout boxes in the output. + tests: + - Place_LongEdge_RectCountEqualsInputNodeCount + - Place_LongEdge_RoutesViaDummyNodesWithinBounds + + - id: Rendering-Layout-InterconnectionEngine-Waypoints + title: >- + InterconnectionLayoutEngine shall return one waypoint list per input edge; a span-one edge + produces a straight two-waypoint path and a long edge routes via dummy nodes within the + layout bounds. + justification: | + Pre-computed waypoints allow the public algorithm and the interconnection view to emit + connector lines without any additional routing logic. + tests: + - Place_SingleEdge_ProducesStraightTwoWaypointPath + - Place_LongEdge_RoutesViaDummyNodesWithinBounds + + - id: Rendering-Layout-InterconnectionEngine-Deterministic + title: >- + Given identical input, InterconnectionLayoutEngine shall produce identical geometry and + waypoints on every invocation. + justification: | + Reproducible layout is required so regenerating a view from unchanged input does not shift + boxes or change connector routes. + tests: + - Place_LinearChain_MonotonicLayerAssignment + - Place_WorkstationTopology_CorrectLayersAndNoOverlap diff --git a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml new file mode 100644 index 0000000..23eb29a --- /dev/null +++ b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml @@ -0,0 +1,219 @@ +--- +# DemaConsulting.Rendering.Layout - Layered Pipeline Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the layered-pipeline unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: Layered Pipeline Unit Requirements + requirements: + - id: Rendering-Layout-LayeredPipeline-StagedPipeline + title: >- + The layered pipeline shall compute a layout by running an ordered sequence of composable + stages, each implementing a common stage interface, over a single shared graph state. + justification: | + Expressing the algorithm as ordered, independently testable stages lets the pipeline be + extended, reordered, and verified one stage at a time instead of as one monolithic method. + tests: + - LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing + - Pipeline_MatchesLegacyOracle_OnRandomGraphs + + - id: Rendering-Layout-LayeredPipeline-BehaviorPreserving + title: >- + The layered pipeline shall produce the same rectangles, totals, layer assignments, and + connector waypoints as the legacy monolithic interconnection engine for every input. + justification: | + The pipeline was extracted from the monolithic engine as a behavior-preserving refactor; + identical geometry is required so existing views render unchanged. + tests: + - Pipeline_MatchesLegacyOracle_OnRandomGraphs + - Pipeline_MatchesLegacyOracle_OnEmptyGraph + - Pipeline_MatchesLegacyOracle_OnDroneLikeGraph + - Pipeline_MatchesLegacyOracle_OnWorkstationLikeGraph + - Pipeline_MatchesLegacyOracle_OnNamedTopologies + + - id: Rendering-Layout-LayeredPipeline-FlatHierarchyOnly + title: >- + The layered pipeline shall support flat (single-graph) hierarchy handling and shall reject + recursive hierarchy handling with a clear error until it is implemented. + justification: | + Flat handling reproduces the current behavior exactly; failing fast on the unimplemented + recursive mode prevents silently producing an unsupported layout. + tests: + - LayeredLayoutPipeline_Build_RecursiveHierarchy_ThrowsNotSupportedException + + - id: Rendering-Layout-LayeredPipeline-Directions + title: >- + The layered pipeline shall support laying out graphs in the right, down, left, and up flow + directions, mapping the abstract left-to-right computation onto screen coordinates for the + requested direction via the axis-transform stage. + justification: | + The stages compute in the left-to-right abstract axes and the axis transform maps the + result onto the requested direction; directed views rely on the down direction, so all + four directions must be supported. + tests: + - AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged + - AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces + - AxisTransform_Apply_Down_PlacesTargetSouthWithCorrectFaces + - AxisTransform_Apply_Left_PlacesTargetWestWithCorrectFaces + - AxisTransform_Apply_Up_PlacesTargetNorthWithCorrectFaces + + - id: Rendering-Layout-LayeredPipeline-OrthogonalConnectors + title: >- + The layered pipeline shall emit connector waypoints composed solely of axis-aligned + segments in every supported flow direction. + justification: | + Orthogonal connectors are the diagram convention; after mapping to any of the four screen + directions, consecutive waypoints must still share an X or a Y coordinate. + tests: + - AxisTransform_Apply_Right_ProducesOrthogonalWaypoints + - AxisTransform_Apply_Down_ProducesOrthogonalWaypoints + - AxisTransform_Apply_Left_ProducesOrthogonalWaypoints + - AxisTransform_Apply_Up_ProducesOrthogonalWaypoints + + - id: Rendering-Layout-LayeredPipeline-CycleBreaking + title: >- + The cycle-breaking stage shall reverse cycle-causing edges to produce an acyclic edge set + and shall drop self-loops and duplicate directed edges. + justification: | + Longest-path layering requires an acyclic graph; reversing back edges and discarding + self-loops and duplicates yields the acyclic edge set the later stages assume. + tests: + - CycleBreaker_Apply_GraphWithCycle_ProducesAcyclicEdgeSet + - CycleBreaker_Apply_SelfLoopsAndDuplicates_AreRemoved + + - id: Rendering-Layout-LayeredPipeline-LayerAssignment + title: >- + The layer-assignment stage shall assign each node the longest-path layer index so that + every edge runs from a strictly lower layer to a strictly higher layer. + justification: | + Longest-path layering places every node as far right as its dependencies allow, guaranteeing + monotonic layer progression and preventing same-layer connections. + tests: + - LayerAssigner_Apply_LinearChain_AssignsMonotonicLayers + - LayerAssigner_Apply_DiamondGraph_AssignsLongestPathLayers + + - id: Rendering-Layout-LayeredPipeline-LongEdgeSplitting + title: >- + The long-edge-splitting stage shall insert one dummy node per intermediate layer for each + edge spanning more than one layer and shall add no dummy node for a span-one edge. + justification: | + Dummy nodes reserve inter-layer corridors so long connectors route around, rather than + through, the boxes of intervening layers. + tests: + - LongEdgeSplitter_Apply_SpanOneEdge_AddsNoDummyNodes + - LongEdgeSplitter_Apply_LongEdge_InsertsDummyNodesPerIntermediateLayer + + - id: Rendering-Layout-LayeredPipeline-CrossingMinimization + title: >- + The crossing-minimization stage shall order the augmented nodes within each layer to reduce + edge crossings while retaining every augmented node in its layer group. + justification: | + Fewer edge crossings produce a more readable diagram; the ordering must reduce crossings + without ever losing a node from its layer. + tests: + - CrossingMinimizer_Apply_CrossingProneOrdering_ReducesCrossings + - CrossingMinimizer_Apply_TwoLayerGraph_GroupsNodesByLayer + - CrossingMinimizer_Apply_AllAugmentedNodesAppearInGroups + + - id: Rendering-Layout-LayeredPipeline-CoordinateAssignment + title: >- + The coordinate-assignment stage shall assign every augmented node absolute coordinates with + layer columns ordered left to right and forks centered between their targets. + justification: | + Brandes-Kopf placement fixes each node's position with layers as ordered columns and + symmetric structures centered, giving the diagram its balanced final geometry. + tests: + - BrandesKopfPlacer_Apply_ChainGraph_AssignsCoordinateArrays + - BrandesKopfPlacer_Apply_ColumnsAreLeftToRightInLayerOrder + - BrandesKopfPlacer_Apply_SymmetricFork_CentersSourceBetweenTargets + + - id: Rendering-Layout-LayeredPipeline-PortDistribution + title: >- + The port-distribution stage shall assign each sub-edge a source-side and target-side port + that lies within the corresponding node face. + justification: | + Distributing ports along box faces keeps parallel connectors from overlapping and ensures + every sub-edge leaves and enters within the bounds of its node's face. + tests: + - PortDistributor_Apply_SingleEdge_PortsLieWithinNodeFaces + - PortDistributor_Apply_AssignsPortYForEverySubEdge + + - id: Rendering-Layout-LayeredPipeline-OrthogonalRouting + title: >- + The orthogonal-routing stage shall emit axis-aligned bend points per corridor, producing no + bend points for a straight sub-edge. + justification: | + Slot-based orthogonal routing gives every connector its right-angled shape; a sub-edge whose + endpoints already align needs no bends. + tests: + - OrthogonalRouter_Apply_StraightEdge_ProducesNoBendPoints + - OrthogonalRouter_Apply_EveryBendListIsEmptyOrVerticalSegment + + - id: Rendering-Layout-LayeredPipeline-BackEdgeApproach + title: >- + The orthogonal-routing stage shall reserve at least the configured back-edge entry approach + for a reversed edge's final segment, defaulting to a value that leaves forward geometry + byte-identical to the legacy engine. + justification: | + A decoration-aware caller can lengthen the reversed-edge approach so an end marker clears the + box, while the default keeps forward and acyclic geometry unchanged. + tests: + - OrthogonalRouter_DefaultApproach_IsByteIdenticalToLegacy + - OrthogonalRouter_CustomApproach_PushesEntryStubOutward + - OrthogonalRouter_ReversedEdge_DefaultApproachClearsClearance + - OrthogonalRouter_ForwardEdges_GeometryUnchanged + - OrthogonalRouter_AcyclicGraph_NoApproachChange + - OrthogonalRouter_DecorationAwareApproach_ClearsMarkerAlongLength + + - id: Rendering-Layout-LayeredPipeline-LongEdgeJoining + title: >- + The long-edge-joining stage shall concatenate the bend points of a split edge's sub-edges + into one polyline per original edge. + justification: | + A long edge split across dummy nodes must be reassembled into a single connector polyline so + consumers see one route per original edge. + tests: + - LongEdgeJoiner_Apply_SingleEdge_ProducesWaypointsPerOriginalEdge + - LongEdgeJoiner_Apply_LongEdge_ConcatenatesSubEdgeBendPoints + + - id: Rendering-Layout-LayeredPipeline-ComponentPacking + title: >- + The component-packing stage shall lay out each connected component independently and pack + the components without overlap in a deterministic order. + justification: | + Disconnected graphs must place each component with its own layered layout and then arrange + the components compactly and reproducibly so nothing overlaps. + tests: + - ComponentPacker_Apply_DisconnectedSingletons_PackSeparately + - ComponentPacker_Apply_ConnectedCore_StaysOneComponent + - ComponentPacker_Apply_SingleComponent_EqualsDefaultPipeline + - ComponentPacker_Apply_ComponentOrder_IsDeterministic + - ComponentPacker_Apply_Waypoints_TranslatedWithComponent + - ComponentPacker_Apply_EmptyGraph_IsNoOp + - ComponentPacker_Apply_SingleComponent_ParallelAndSelfEdges_ProducesAlignedWaypoints + - ComponentPacker_Apply_MultiComponent_ParallelAndSelfEdges_MergesAlignedWaypoints + + - id: Rendering-Layout-LayeredPipeline-SharedState + title: >- + The layered graph shall carry the mutable shared state threaded through every stage and + shall reject construction with null nodes or null edges. + justification: | + A single shared state object replaces the ad-hoc locals of the monolithic engine; rejecting + null inputs at construction keeps every downstream stage operating on valid state. + tests: + - LayeredGraph_Constructor_ValidInput_StoresNodesEdgesDirectionAndCount + - LayeredGraph_Constructor_NullNodes_ThrowsArgumentNullException + - LayeredGraph_Constructor_NullEdges_ThrowsArgumentNullException + + - id: Rendering-Layout-LayeredPipeline-InputValidation + title: >- + The layered pipeline shall reject a null stage when assembled and a null graph when run, + throwing an argument-null error in each case. + justification: | + Failing fast on null construction and run arguments prevents malformed pipelines from + silently producing incorrect or partial layouts. + tests: + - LayeredLayoutPipeline_AddStage_NullStage_ThrowsArgumentNullException + - LayeredLayoutPipeline_Run_NullGraph_ThrowsArgumentNullException diff --git a/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml b/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml new file mode 100644 index 0000000..26ea6f4 --- /dev/null +++ b/docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml @@ -0,0 +1,76 @@ +--- +# DemaConsulting.Rendering.Layout - OrthogonalEdgeRouter Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the orthogonal-edge-router unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: OrthogonalEdgeRouter Unit Requirements + requirements: + - id: Rendering-Layout-OrthogonalEdgeRouter-Orthogonal + title: >- + OrthogonalEdgeRouter shall produce a path of axis-aligned segments connecting the source anchor + to the target anchor. + justification: | + Orthogonal (axis-aligned) connectors are the convention for the block, state, and activity + diagrams the library renders. Consecutive waypoints therefore always share an X or a Y + coordinate. + tests: + - Route_NoObstacles_ProducesOrthogonalPath + - Route_AlignedEndpoints_ProducesStraightLine + + - id: Rendering-Layout-OrthogonalEdgeRouter-AvoidObstacles + title: >- + When an obstacle-free path exists, OrthogonalEdgeRouter shall route around obstacle rectangles + without passing through their interiors. + justification: | + Connectors that cut through unrelated boxes are unreadable. The router must steer segments + around obstacles whenever the geometry allows. + tests: + - Route_ObstacleBetween_RoutesAround + - Route_MultipleObstacles_RemainsValid + - RouteWithStatus_ObstacleBetween_RoutesAroundWithoutCrossing + + - id: Rendering-Layout-OrthogonalEdgeRouter-Clearance + title: >- + When an obstacle-free path is achievable, OrthogonalEdgeRouter shall keep routed segments clear + of obstacles by the caller-specified clearance distance. + justification: | + A connector that grazes the edge of a box reads as touching it. Honoring a clearance keeps + visible breathing space between connectors and unrelated boxes. + tests: + - RouteWithStatus_CleanRoute_KeepsClearanceFromObstacles + + - id: Rendering-Layout-OrthogonalEdgeRouter-PerpendicularEnds + title: >- + When an anchor is given with the box side it sits on, OrthogonalEdgeRouter shall leave or enter + that anchor with a segment perpendicular to the side. + justification: | + Connectors that meet a box edge at a right angle read as attached to the box rather than + sliding along its border, matching diagram notation conventions. + tests: + - Route_WithSourceSide_LeavesPerpendicular + - Route_WithTargetSide_EntersPerpendicular + + - id: Rendering-Layout-OrthogonalEdgeRouter-CrossingStatus + title: >- + OrthogonalEdgeRouter shall report whether the produced path could not be routed without crossing + an obstacle. + justification: | + When geometry forces a connector through a box (for example an enclosed target), callers + need to know so the condition can be surfaced as a layout warning rather than silently + producing a misleading diagram. + tests: + - RouteWithStatus_NoBlockingObstacle_ReportsNotCrossed + - RouteWithStatus_TargetEnclosedByObstacle_ReportsCrossed + + - id: Rendering-Layout-OrthogonalEdgeRouter-CostBands + title: >- + When cost bands are supplied, OrthogonalEdgeRouter shall prefer routing through a cheaper band + over an equal-length alternative outside it. + justification: | + Highway bundling depends on a cost discount so parallel wires share a corridor; the router + must bias toward discounted bands while keeping null bands neutral. + tests: + - RouteWithStatus_HighwayBand_PrefersBandedDetour diff --git a/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml new file mode 100644 index 0000000..488e063 --- /dev/null +++ b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml @@ -0,0 +1,102 @@ +--- +# DemaConsulting.Rendering.Layout - HierarchicalLayoutAlgorithm Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the hierarchical-layout-algorithm unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: HierarchicalLayoutAlgorithm Unit Requirements + requirements: + - id: Rendering-Layout-HierarchicalLayout-Identity + title: >- + HierarchicalLayoutAlgorithm 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 hierarchical engine be resolved by name alongside the leaf algorithms. + tests: + - Id_IsHierarchical + + - id: Rendering-Layout-HierarchicalLayout-FlatEquivalence + title: >- + When given a graph in which no node is a container, HierarchicalLayoutAlgorithm shall produce + a placed layout tree identical to the selected leaf algorithm applied to the same graph. + justification: | + The engine is purely additive: a flat graph must lay out exactly as it did before the + hierarchical engine existed, so selecting the engine can never change any existing output. + tests: + - Apply_FlatRandomGraphs_MatchLayeredAlgorithmExactly + - Apply_FlatRandomGraphs_MatchContainmentAlgorithmExactly + + - id: Rendering-Layout-HierarchicalLayout-NestsChildren + title: >- + HierarchicalLayoutAlgorithm shall place a container node's children within that container's + bounds and size the container to enclose them. + justification: | + Nested content is only meaningful when it renders inside its container; sizing each container + to its children and positioning the children within its bounds is the engine's core behavior. + tests: + - Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely + - Apply_ThreeLevelNesting_Succeeds + + - id: Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm + title: >- + HierarchicalLayoutAlgorithm shall lay out each scope with the algorithm selected on that scope, + allowing a container to be arranged by a different algorithm than its parent. + justification: | + Different levels of a diagram suit different arrangements; letting each container select its + own algorithm — inheriting the parent's when unset — lets, for example, a packed container hold + a layered one without either level knowing about the other. + tests: + - Apply_ContainmentRootWithLayeredContainer_Composes + - Apply_LayeredRootWithContainmentContainer_Composes + + - id: Rendering-Layout-HierarchicalLayout-HierarchyHandling + title: >- + HierarchicalLayoutAlgorithm shall lay out each container's children in their own coordinate + space and compose them into the parent at absolute coordinates. + justification: | + Laying out each container independently and then composing it into its parent keeps container + placements deterministic and lets sub-layouts be reused as self-contained regions, mirroring + the separate-children hierarchy-handling mode. + tests: + - Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely + - Apply_CompoundGraph_DoesNotMutateInputNodeSizes + + - id: Rendering-Layout-HierarchicalLayout-CrossContainerEdge + title: >- + HierarchicalLayoutAlgorithm shall route an edge whose endpoints live in different containers at + the scope that owns it, steering around any intervening container rather than through it. + justification: | + A cross-container edge belongs to the coordinate space of the container that owns it; routing + it around the sibling containers between its endpoints keeps the connector unambiguous. + tests: + - Apply_CrossContainerEdge_RoutesAroundInterveningContainer + + - id: Rendering-Layout-HierarchicalLayout-ValidatesGraph + title: >- + HierarchicalLayoutAlgorithm shall reject a null graph argument with an argument-null error. + justification: | + Failing fast on a null graph prevents undefined behavior and surfaces caller errors at the + public algorithm boundary. + tests: + - Apply_NullGraph_Throws + + - id: Rendering-Layout-HierarchicalLayout-ValidatesOptions + title: >- + HierarchicalLayoutAlgorithm shall reject a null options argument with an argument-null error. + justification: | + Failing fast on null options prevents undefined behavior and surfaces caller errors at the + public algorithm boundary. + tests: + - Apply_NullOptions_Throws + + - id: Rendering-Layout-HierarchicalLayout-ValidatesRegistry + title: >- + HierarchicalLayoutAlgorithm shall reject a null registry argument with an argument-null error. + justification: | + Failing fast on a null registry prevents undefined behavior and surfaces caller errors at the + construction boundary. + tests: + - Constructor_NullRegistry_Throws diff --git a/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml b/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml new file mode 100644 index 0000000..64869b2 --- /dev/null +++ b/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml @@ -0,0 +1,50 @@ +--- +# DemaConsulting.Rendering.Layout - LayeredLayoutAlgorithm Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the layered-layout-algorithm unit. +# - This file is part of the split Rendering.Layout requirements set. + +sections: + - title: LayeredLayoutAlgorithm Unit Requirements + requirements: + - id: Rendering-Layout-LayeredAlgorithm-Identity + title: >- + LayeredLayoutAlgorithm 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 layered algorithm be resolved by name. + tests: + - Id_IsLayered + + - id: Rendering-Layout-LayeredAlgorithm-PlacesAndRoutes + title: >- + LayeredLayoutAlgorithm shall convert a layout graph into a placed layout tree with one box + per node and one orthogonally routed connector per edge. + justification: | + The algorithm is the public bridge from an abstract graph to concrete geometry; every node + must become a placed box and every edge a routed connector for renderers to draw. + tests: + - Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges + + - id: Rendering-Layout-LayeredAlgorithm-EmptyGraph + title: >- + When given a graph with no nodes, LayeredLayoutAlgorithm 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_ReturnsEmptyCanvas + + - id: Rendering-Layout-LayeredAlgorithm-Validation + title: >- + LayeredLayoutAlgorithm 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_Throws + - Apply_NullOptions_Throws diff --git a/docs/reqstream/rendering-layout/rendering-layout.yaml b/docs/reqstream/rendering-layout/rendering-layout.yaml new file mode 100644 index 0000000..5862365 --- /dev/null +++ b/docs/reqstream/rendering-layout/rendering-layout.yaml @@ -0,0 +1,197 @@ +--- +# Rendering.Layout System Requirements +# +# PURPOSE: +# - 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. +# - Requirements describe observable geometric behavior, not the algorithms used. +# +# STRUCTURE: +# - The first section holds system-level requirements that decompose into the units and the Engine +# subsystem. The subsystem and per-unit requirements live in the sibling files +# engine/*.yaml and {unit-name}.yaml. + +sections: + - title: Rendering.Layout System Requirements + requirements: + - id: Rendering-Layout-Algorithm + title: >- + The rendering layout system shall provide a named layout algorithm that arranges a layout + graph into a placed layout tree of layered boxes and orthogonally routed connectors. + justification: | + The system's product boundary is the public layout algorithm consumed by renderers. It + converts a caller's abstract graph into concrete placed geometry so downstream SVG and PNG + renderers can draw the diagram without any layout knowledge of their own. + children: + - Rendering-Layout-LayeredAlgorithm-Identity + - Rendering-Layout-LayeredAlgorithm-PlacesAndRoutes + - Rendering-Layout-LayeredAlgorithm-EmptyGraph + - Rendering-Layout-LayeredAlgorithm-Validation + tests: + - Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges + - Id_IsLayered + + - id: Rendering-Layout-GeometricEngines + title: >- + The rendering layout system shall be built from reusable, model-agnostic geometric engines + that provide orthogonal connector routing, containment row-packing, and layered + interconnection placement as independently verifiable capabilities. + justification: | + Grouping the geometric engines into a single subsystem keeps the layout algorithm free of + model knowledge and lets each engine be developed, verified, and reused on its own. The + public algorithm composes these engines to turn an abstract graph into placed geometry. + children: + - Rendering-Layout-OrthogonalRouting + - Rendering-Layout-Containment + - Rendering-Layout-Interconnection + tests: + - Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges + + - id: Rendering-Layout-StagedPipeline + title: >- + The rendering layout system shall compute layered placement through a composable, ordered + sequence of ELK-style stages over a single shared graph state object. + justification: | + Expressing the algorithm as ordered, independently testable stages lets the pipeline be + extended, reordered, and verified one stage at a time instead of as one monolithic method. + children: + - Rendering-Layout-LayeredPipeline-StagedPipeline + - Rendering-Layout-LayeredPipeline-BehaviorPreserving + - Rendering-Layout-LayeredPipeline-FlatHierarchyOnly + - Rendering-Layout-LayeredPipeline-Directions + - Rendering-Layout-LayeredPipeline-OrthogonalConnectors + - Rendering-Layout-LayeredPipeline-CycleBreaking + - Rendering-Layout-LayeredPipeline-LayerAssignment + - Rendering-Layout-LayeredPipeline-LongEdgeSplitting + - Rendering-Layout-LayeredPipeline-CrossingMinimization + - Rendering-Layout-LayeredPipeline-CoordinateAssignment + - Rendering-Layout-LayeredPipeline-PortDistribution + - Rendering-Layout-LayeredPipeline-OrthogonalRouting + - Rendering-Layout-LayeredPipeline-BackEdgeApproach + - Rendering-Layout-LayeredPipeline-LongEdgeJoining + - Rendering-Layout-LayeredPipeline-ComponentPacking + - Rendering-Layout-LayeredPipeline-SharedState + - Rendering-Layout-LayeredPipeline-InputValidation + tests: + - LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing + - Pipeline_MatchesLegacyOracle_OnRandomGraphs + + - id: Rendering-Layout-ConnectorRouting + title: >- + The rendering layout system shall route connectors among already-placed boxes, choosing a + routing style per scope and producing one placed connector line per connection. + justification: | + Placing boxes is only half of a diagram; the connectors joining them must be routed among + the placed geometry. Exposing a model-agnostic routing orchestration lets any caller connect + two boxes without reimplementing anchor selection, obstacle avoidance, and per-connector + routing, and lets the routing style be selected through the same open property system that + selects the algorithm. + children: + - Rendering-Layout-EdgeRouting-Selection + - Rendering-Layout-EdgeRouting-Defaults + - Rendering-Layout-ConnectorRouter-AnchorsFaceEachOther + - Rendering-Layout-ConnectorRouter-AvoidsObstacles + - Rendering-Layout-ConnectorRouter-ExcludesEndpoints + - Rendering-Layout-ConnectorRouter-CarriesStyling + - Rendering-Layout-ConnectorRouter-BatchOrder + - Rendering-Layout-ConnectorRouter-Validation + tests: + - Route_TargetToTheRight_AnchorsFaceEachOther + - Route_ObstacleBetweenEndpoints_RoutesAroundInterior + + - id: Rendering-Layout-ContainmentPlacement + title: >- + The rendering layout system shall pack a set of already-sized model boxes into a single + container region, arranging them into rows within a width budget and reporting the enclosing + region size. + justification: | + A container groups peer elements whose reading order, not their connectivity, drives the + layout. Exposing a model-speaking packing operation lets any caller arrange boxes inside a + container without reimplementing row packing, and returns both the repositioned boxes and the + region size a renderer needs to draw the container, keeping container sizing consistent across + every view that nests elements. + children: + - Rendering-Layout-ContainmentLayout-Order + - Rendering-Layout-ContainmentLayout-NoOverlap + - Rendering-Layout-ContainmentLayout-WithinRegion + - Rendering-Layout-ContainmentLayout-Wrapping + - Rendering-Layout-ContainmentLayout-OversizedChild + - Rendering-Layout-ContainmentLayout-EmptyInput + - Rendering-Layout-ContainmentLayout-PreservesFields + - Rendering-Layout-ContainmentLayout-Defaults + - Rendering-Layout-ContainmentLayout-Validation + tests: + - Pack_ItemsFitInRow_PreservesOrderLeftToRight + - Pack_MixedSizes_ProducesNoOverlaps + + - id: Rendering-Layout-ContainmentAlgorithm + title: >- + The rendering layout system shall provide a named containment layout algorithm that packs a + layout graph's top-level nodes into rows and routes each edge around the packed boxes. + justification: | + Some diagrams group their elements as peers whose reading order, not their connectivity, + drives placement. Exposing a second bundled algorithm that packs nodes into a balanced block + and routes the edges around them lets a caller select this arrangement by name, additively, + without changing the layered algorithm or any existing output. + children: + - Rendering-Layout-ContainmentAlgorithm-Identity + - Rendering-Layout-ContainmentAlgorithm-PacksNodes + - Rendering-Layout-ContainmentAlgorithm-RoutesEdges + - Rendering-Layout-ContainmentAlgorithm-RoutesAroundObstacle + - Rendering-Layout-ContainmentAlgorithm-EmptyGraph + - Rendering-Layout-ContainmentAlgorithm-SkipsOutOfGraphEdges + - Rendering-Layout-ContainmentAlgorithm-Validation + tests: + - Apply_Graph_PacksNodesNonOverlappingInInputOrderWithinCanvas + - Id_IsContainment + + - id: Rendering-Layout-HierarchicalLayout + title: >- + The rendering layout system shall provide a named hierarchical layout algorithm that lays out + a compound layout graph by placing each container's children within their own region, sizing + each container to enclose its children, and routing edges that cross container boundaries. + justification: | + Diagrams often nest groups within groups — a package holding its members, a block holding its + parts. Exposing a recursive engine that composes the bundled leaf algorithms lets a caller lay + out a nested graph by name, additively, while guaranteeing that a graph with no nesting is + placed exactly as the selected leaf algorithm would place it so no existing output changes. + children: + - Rendering-Layout-HierarchicalLayout-Identity + - Rendering-Layout-HierarchicalLayout-FlatEquivalence + - Rendering-Layout-HierarchicalLayout-NestsChildren + - Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm + - Rendering-Layout-HierarchicalLayout-HierarchyHandling + - Rendering-Layout-HierarchicalLayout-CrossContainerEdge + - Rendering-Layout-HierarchicalLayout-ValidatesGraph + - Rendering-Layout-HierarchicalLayout-ValidatesOptions + - Rendering-Layout-HierarchicalLayout-ValidatesRegistry + tests: + - Id_IsHierarchical + - Apply_FlatRandomGraphs_MatchLayeredAlgorithmExactly + + - id: Rendering-Layout-DefaultLayout + title: >- + The rendering layout system shall provide a batteries-included facade that lays out a layout + graph with the algorithm the graph or options declares, resolved from the bundled algorithms, + defaulting to the hierarchical engine so both flat and nested graphs lay out from one call. + justification: | + The most common request — "lay out my graph with whatever algorithm it declares" — should be a + single call, not a registry-assembly exercise. Exposing a default registry factory and a thin + layout facade that defaults to the hierarchical engine lets a caller lay out either a flat or a + nested graph without choosing an engine, while preserving every existing output because the + hierarchical engine is byte-identical to the leaf algorithm for a flat graph. + children: + - Rendering-Layout-DefaultRegistry-BundledAlgorithms + - Rendering-Layout-LayoutEngine-DefaultAlgorithm + - Rendering-Layout-LayoutEngine-Resolution + - Rendering-Layout-LayoutEngine-FlatEquivalence + - Rendering-Layout-LayoutEngine-NestedComposition + - Rendering-Layout-LayoutEngine-CustomRegistry + - Rendering-Layout-LayoutEngine-Validation + tests: + - CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms + - Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly diff --git a/docs/reqstream/rendering-skia/jpeg-renderer.yaml b/docs/reqstream/rendering-skia/jpeg-renderer.yaml new file mode 100644 index 0000000..4b2b088 --- /dev/null +++ b/docs/reqstream/rendering-skia/jpeg-renderer.yaml @@ -0,0 +1,21 @@ +--- +# DemaConsulting.Rendering.Skia - JpegRenderer Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the JpegRenderer unit: the concrete Skia renderer that encodes the +# shared rasterized bitmap as JPEG. +# - These unit requirements decompose the Rendering-Skia-RenderRasterImage system requirement +# declared in rendering-skia.yaml. + +sections: + - title: JpegRenderer Unit Requirements + requirements: + - id: Rendering-Skia-JpegRenderer-EmitsJpeg + title: >- + JpegRenderer shall encode the rasterized diagram as a JPEG image, reporting the + "image/jpeg" media type and the ".jpg" and ".jpeg" file extensions. + justification: | + JPEG produces small, widely-compatible images for diagrams where lossy compression is + acceptable; advertising both extensions lets callers resolve the renderer by either name. + tests: + - JpegRenderer_Render_ProducesJpegSignature diff --git a/docs/reqstream/rendering-skia/png-renderer.yaml b/docs/reqstream/rendering-skia/png-renderer.yaml new file mode 100644 index 0000000..6b3b9b2 --- /dev/null +++ b/docs/reqstream/rendering-skia/png-renderer.yaml @@ -0,0 +1,23 @@ +--- +# DemaConsulting.Rendering.Skia - PngRenderer Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the PngRenderer unit: the concrete Skia renderer that encodes the +# shared rasterized bitmap as PNG. +# - These unit requirements decompose the Rendering-Skia-RenderRasterImage system requirement +# declared in rendering-skia.yaml. + +sections: + - title: PngRenderer Unit Requirements + requirements: + - id: Rendering-Skia-PngRenderer-EmitsPng + title: >- + PngRenderer shall encode the rasterized diagram as a lossless PNG image, reporting the + "image/png" media type and the ".png" file extension. + justification: | + PNG is the default lossless raster format for diagrams; reporting its media type and + extension lets callers select and name the output correctly. + tests: + - Render_SingleBox_ProducesPngSignature + - PngRenderer_Render_EmptyTree_WritesPngSignature + - PngRenderer_FileExtensions_ContainsDefault diff --git a/docs/reqstream/rendering-skia/rendering-skia.yaml b/docs/reqstream/rendering-skia/rendering-skia.yaml new file mode 100644 index 0000000..7e34106 --- /dev/null +++ b/docs/reqstream/rendering-skia/rendering-skia.yaml @@ -0,0 +1,38 @@ +--- +# Rendering.Skia System Requirements +# +# PURPOSE: +# - Define the system-level requirement for the DemaConsulting.Rendering.Skia system: the +# SkiaSharp raster renderer package that renders placed layout trees to PNG, JPEG, and WEBP. +# - The system requirement is generic: it describes WHAT the Skia raster system delivers. It +# decomposes downward (children) into the unit requirements that provide the detailed behaviour. +# Those unit requirements live in the per-unit sibling files: +# - skia-raster-renderer.yaml (the shared SkiaSharp rasterizer) +# - png-renderer.yaml (PNG encoding) +# - jpeg-renderer.yaml (JPEG encoding) +# - webp-renderer.yaml (WEBP encoding) +# - Each unit requirement is verified by SkiaSharp renderer unit tests. + +sections: + - title: Rendering.Skia Requirements + requirements: + - id: Rendering-Skia-RenderRasterImage + title: >- + The Skia system shall render a placed layout tree to raster images in the PNG, JPEG, + and WEBP formats using SkiaSharp. + justification: | + Raster output lets rendered diagrams be embedded where vector formats are not accepted, + shared as ordinary image files, and previewed by any image viewer. PNG, JPEG, and WEBP + are the formats SkiaSharp encodes reliably across all supported platforms. + children: + - Rendering-Skia-SkiaRasterRenderer-DrawsLayoutTree + - Rendering-Skia-SkiaRasterRenderer-ThemeColours + - Rendering-Skia-SkiaRasterRenderer-EndMarkers + - Rendering-Skia-SkiaRasterRenderer-EmptyTree + - Rendering-Skia-PngRenderer-EmitsPng + - Rendering-Skia-JpegRenderer-EmitsJpeg + - Rendering-Skia-WebpRenderer-EmitsWebp + tests: + - Render_SingleBox_ProducesPngSignature + - JpegRenderer_Render_ProducesJpegSignature + - WebpRenderer_Render_ProducesWebpContainerHeader diff --git a/docs/reqstream/rendering-skia/skia-raster-renderer.yaml b/docs/reqstream/rendering-skia/skia-raster-renderer.yaml new file mode 100644 index 0000000..e3399a9 --- /dev/null +++ b/docs/reqstream/rendering-skia/skia-raster-renderer.yaml @@ -0,0 +1,68 @@ +--- +# DemaConsulting.Rendering.Skia - SkiaRasterRenderer Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the SkiaRasterRenderer unit: the shared SkiaSharp rasterizer that +# draws placed layout-tree nodes to a bitmap before concrete format renderers encode it. +# - These unit requirements decompose the Rendering-Skia-RenderRasterImage system requirement +# declared in rendering-skia.yaml. + +sections: + - title: SkiaRasterRenderer Unit Requirements + requirements: + - id: Rendering-Skia-SkiaRasterRenderer-DrawsLayoutTree + title: >- + SkiaRasterRenderer shall rasterize every layout-tree node kind - boxes, labels, lines, + ports, badges, bands, lifelines, activations, and grids - onto a bitmap initialized with + the render theme's background color. + justification: | + The base rasterizer draws all node kinds so that every concrete format renderer produces + a complete diagram; the theme background makes the output usable in formats without + transparency, such as JPEG, while keeping the canvas fill, hollow-marker occlusion, and + midpoint-label backplates consistent under non-white themes. + tests: + - PngRenderer_Render_SingleBox_ProducesNonEmptyOutput + - PngRenderer_Render_BackgroundIsThemeBackground + - PngRenderer_Render_SingleLine_PixelOnLineIsStrokeColor + - PngRenderer_Render_SinglePort_CenterPixelIsStrokeColor + - PngRenderer_Render_SingleBadge_FilledCircle_CenterPixelIsStrokeColor + - PngRenderer_Render_SingleLifeline_StemPixelIsStrokeColor + - PngRenderer_Render_SingleActivation_CenterPixelIsWhite + - PngRenderer_Render_SingleBand_BorderIsStrokeColor + - PngRenderer_Render_DeeplyNestedBoxes_DoesNotStackOverflow + - PngRenderer_Render_LabelWithXmlSpecialCharacters_ProducesValidPng + + - id: Rendering-Skia-SkiaRasterRenderer-ThemeColours + title: >- + SkiaRasterRenderer shall derive box and grid fill colours from the render theme, indexing + the depth-fill palette by nesting depth. + justification: | + Deriving colours from the theme keeps raster output visually consistent with the SVG + renderer and lets callers restyle diagrams by supplying a different theme. + tests: + - PngRenderer_Render_SingleBox_FillColorMatchesTheme + - PngRenderer_Render_SingleBox_DepthOneUsesSecondColor + - PngRenderer_Render_SingleGrid_HeaderFillMatchesTheme + + - id: Rendering-Skia-SkiaRasterRenderer-EndMarkers + title: >- + SkiaRasterRenderer shall draw connector end markers whose geometry derives from the shared + notation metrics, so raster markers match the SVG markers. + justification: | + End markers express relationship direction and kind; sharing the notation metrics with the + SVG renderer keeps the two output formats visually identical. + tests: + - FilledArrow_AlongLength_MatchesNotationMetrics + - FilledArrow_BaseWidth_MatchesNotationMetrics + - OpenChevron_HasFewerInkPixelsThanClosedTriangle + - PngRenderer_Render_DrawArrowhead_OpenWithCrossbar_ProducesNonEmptyOutput + + - id: Rendering-Skia-SkiaRasterRenderer-EmptyTree + title: >- + SkiaRasterRenderer shall render an empty layout tree to a valid image with a minimum + bitmap size of one by one pixels. + justification: | + Enforcing a minimum bitmap size prevents SkiaSharp allocation errors for empty diagrams, + so callers never have to special-case an empty layout. + tests: + - PngRenderer_Render_EmptyTree_WritesPngSignature diff --git a/docs/reqstream/rendering-skia/webp-renderer.yaml b/docs/reqstream/rendering-skia/webp-renderer.yaml new file mode 100644 index 0000000..65772f6 --- /dev/null +++ b/docs/reqstream/rendering-skia/webp-renderer.yaml @@ -0,0 +1,21 @@ +--- +# DemaConsulting.Rendering.Skia - WebpRenderer Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the WebpRenderer unit: the concrete Skia renderer that encodes the +# shared rasterized bitmap as WEBP. +# - These unit requirements decompose the Rendering-Skia-RenderRasterImage system requirement +# declared in rendering-skia.yaml. + +sections: + - title: WebpRenderer Unit Requirements + requirements: + - id: Rendering-Skia-WebpRenderer-EmitsWebp + title: >- + WebpRenderer shall encode the rasterized diagram as a WEBP image, reporting the + "image/webp" media type and the ".webp" file extension. + justification: | + WEBP gives high-quality diagrams at small file sizes for modern web use; reporting its + media type and extension lets callers select and name the output correctly. + tests: + - WebpRenderer_Render_ProducesWebpContainerHeader diff --git a/docs/reqstream/rendering-svg/rendering-svg.yaml b/docs/reqstream/rendering-svg/rendering-svg.yaml new file mode 100644 index 0000000..7e0a381 --- /dev/null +++ b/docs/reqstream/rendering-svg/rendering-svg.yaml @@ -0,0 +1,52 @@ +--- +# DemaConsulting.Rendering.Svg System Requirements +# +# PURPOSE: +# - Define the SYSTEM-level requirement for the DemaConsulting.Rendering.Svg system. +# - The system requirement is generic: it describes WHAT the SVG system delivers. It decomposes +# downward (children) into the SvgRenderer unit requirements that provide the detailed rendering +# behaviour. Those unit requirements live in the sibling file svg-renderer.yaml. + +sections: + - title: Rendering.Svg Requirements + requirements: + - id: Rendering-Svg-WriteSvgDocument + title: >- + The SVG system shall render a placed layout tree to a valid SVG document that + represents the diagram's elements. + justification: | + Producing SVG output lets rendered diagrams be embedded in architecture + documentation, viewed in any web browser, and version-controlled as text. + children: + - Rendering-Svg-SvgRenderer-ImplementsIRenderer + - Rendering-Svg-SvgRenderer-MediaType + - Rendering-Svg-SvgRenderer-DefaultExtension + - Rendering-Svg-SvgRenderer-RenderDocument + - Rendering-Svg-SvgRenderer-RenderEmptyTree + - Rendering-Svg-SvgRenderer-RenderBox + - Rendering-Svg-SvgRenderer-RenderBoxRoundedCorners + - Rendering-Svg-SvgRenderer-RenderBoxCompartments + - Rendering-Svg-SvgRenderer-RenderLabel + - Rendering-Svg-SvgRenderer-RenderLabelBold + - Rendering-Svg-SvgRenderer-RenderLabelItalic + - Rendering-Svg-SvgRenderer-RenderLabelEscaping + - Rendering-Svg-SvgRenderer-RenderLine + - Rendering-Svg-SvgRenderer-RenderLineRoundedCorners + - Rendering-Svg-SvgRenderer-RenderLineDashed + - Rendering-Svg-SvgRenderer-RenderLineMidpointLabel + - Rendering-Svg-SvgRenderer-RenderNodeKinds + - Rendering-Svg-SvgRenderer-RenderBadge + - Rendering-Svg-SvgRenderer-RenderBand + - Rendering-Svg-SvgRenderer-RenderLifeline + - Rendering-Svg-SvgRenderer-RenderActivation + - Rendering-Svg-SvgRenderer-RenderGrid + - Rendering-Svg-SvgRenderer-EndMarkers + - Rendering-Svg-SvgRenderer-EndMarkersOpenChevronReference + - Rendering-Svg-SvgRenderer-HollowTriangleEndMarkers + - Rendering-Svg-SvgRenderer-HollowTriangleEndMarkerReference + - Rendering-Svg-SvgRenderer-TriangleEndMarkerMetrics + - Rendering-Svg-SvgRenderer-DiamondEndMarkers + - Rendering-Svg-SvgRenderer-DiamondEndMarkerReference + - Rendering-Svg-SvgRenderer-CrossbarEndMarkers + tests: + - SvgRenderer_Render_SingleBox_ProducesSvgDocument diff --git a/docs/reqstream/rendering-svg/svg-renderer.yaml b/docs/reqstream/rendering-svg/svg-renderer.yaml new file mode 100644 index 0000000..6378793 --- /dev/null +++ b/docs/reqstream/rendering-svg/svg-renderer.yaml @@ -0,0 +1,261 @@ +--- +# DemaConsulting.Rendering.Svg - SvgRenderer Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the SvgRenderer unit: the stateless SVG renderer that implements +# IRenderer and writes SVG markup for every supported LayoutTree node kind. +# - These unit requirements decompose the Rendering-Svg-WriteSvgDocument system requirement declared +# in rendering-svg.yaml. + +sections: + - title: SvgRenderer Unit Requirements + requirements: + - id: Rendering-Svg-SvgRenderer-ImplementsIRenderer + title: >- + SvgRenderer shall implement the IRenderer interface. + justification: | + Implementing the shared renderer interface lets a caller select SVG output uniformly. + tests: + - SvgRenderer_Render_SingleBox_ProducesSvgDocument + + - id: Rendering-Svg-SvgRenderer-MediaType + title: >- + SvgRenderer shall report the "image/svg+xml" media type. + justification: | + The media type lets callers identify and serve the rendered SVG output correctly. + tests: + - SvgRenderer_Render_SingleBox_ProducesSvgDocument + + - id: Rendering-Svg-SvgRenderer-DefaultExtension + title: >- + SvgRenderer shall report ".svg" as the default file extension. + justification: | + The default extension lets callers name persisted SVG outputs consistently. + tests: + - SvgRenderer_Render_SingleBox_ProducesSvgDocument + + - id: Rendering-Svg-SvgRenderer-RenderDocument + title: >- + SvgRenderer shall write an SVG 1.1 document containing the root svg element. + justification: | + A root svg element is required by every SVG viewer and is the container for all rendered + diagram content. + tests: + - SvgRenderer_Render_EmptyTree_ProducesSvgDocument + + - id: Rendering-Svg-SvgRenderer-RenderEmptyTree + title: >- + SvgRenderer shall write a non-empty SVG document for an empty layout tree. + justification: | + The empty-tree boundary case must still produce a valid SVG container so callers can handle + empty diagrams without special cases. + tests: + - SvgRenderer_Render_EmptyTree_ProducesSvgDocument + + - id: Rendering-Svg-SvgRenderer-RenderBox + title: >- + SvgRenderer shall render each box node as a rect element. + justification: | + Boxes are the primary diagram element, so each box must appear as an SVG rectangle. + tests: + - SvgRenderer_Render_SingleBox_ProducesRectElement + + - id: Rendering-Svg-SvgRenderer-RenderBoxRoundedCorners + title: >- + SvgRenderer shall emit rounded-corner attributes for rounded-rectangle box nodes. + justification: | + Rounded corners convey the notation for rounded-rectangle boxes. + tests: + - SvgRenderer_Render_BoxRoundedRectangle_ProducesRxAttribute + + - id: Rendering-Svg-SvgRenderer-RenderBoxCompartments + title: >- + SvgRenderer shall draw box compartment dividers and row text. + justification: | + Compartments convey structured properties and operations within box notation. + tests: + - SvgRenderer_Render_BoxWithCompartment_ProducesLineAndText + + - id: Rendering-Svg-SvgRenderer-RenderLabel + title: >- + SvgRenderer shall render each label node as a text element. + justification: | + Labels carry element names and annotations that must appear as SVG text. + tests: + - SvgRenderer_Render_SingleLabel_ProducesTextElement + + - id: Rendering-Svg-SvgRenderer-RenderLabelBold + title: >- + SvgRenderer shall apply the bold font-weight attribute for bold label nodes. + justification: | + Bold styling conveys emphasis requested by the placed layout tree. + tests: + - SvgRenderer_Render_LabelWithBold_ProducesBoldAttribute + + - id: Rendering-Svg-SvgRenderer-RenderLabelItalic + title: >- + SvgRenderer shall apply the italic font-style attribute for italic label nodes. + justification: | + Italic styling conveys emphasis requested by the placed layout tree. + tests: + - SvgRenderer_Render_LabelWithItalic_ProducesItalicAttribute + + - id: Rendering-Svg-SvgRenderer-RenderLabelEscaping + title: >- + SvgRenderer shall escape XML-special characters in rendered label text. + justification: | + Escaping keeps the SVG document well-formed for any model text. + tests: + - SvgRenderer_Render_LabelWithXmlSpecialCharacters_ProducesWellFormedEscapedSvg + + - id: Rendering-Svg-SvgRenderer-RenderLine + title: >- + SvgRenderer shall render each connector line as a path element. + justification: | + Connectors express relationships and must appear as SVG paths following their routed + waypoints. + tests: + - SvgRenderer_Render_SingleLine_ProducesPathElement + + - id: Rendering-Svg-SvgRenderer-RenderLineRoundedCorners + title: >- + SvgRenderer shall render rounded connector corners with SVG arc commands. + justification: | + Rounded connector corners convey the theme's configured line-corner radius. + tests: + - SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath + + - id: Rendering-Svg-SvgRenderer-RenderLineDashed + title: >- + SvgRenderer shall render dashed connector lines with a stroke-dasharray attribute. + justification: | + Dashed styling conveys connector semantics requested by the placed layout tree. + tests: + - SvgRenderer_Render_SingleLine_Dashed_ProducesDashArray + + - id: Rendering-Svg-SvgRenderer-RenderLineMidpointLabel + title: >- + SvgRenderer shall render connector midpoint labels as text elements. + justification: | + Connector labels carry relationship names and semantics that must remain visible in the SVG. + tests: + - SvgRenderer_Render_LineWithMidpointLabel_ProducesTextElement + + - id: Rendering-Svg-SvgRenderer-RenderNodeKinds + title: >- + SvgRenderer shall render port nodes as rect elements. + justification: | + Ports pin connection points to box edges and must be visible as SVG rectangles. + tests: + - SvgRenderer_Render_SinglePort_ProducesRect + + - id: Rendering-Svg-SvgRenderer-RenderBadge + title: >- + SvgRenderer shall render filled-circle badge nodes as circle elements. + justification: | + Filled-circle badges are notation icons that must appear as SVG circles. + tests: + - SvgRenderer_Render_SingleBadge_FilledCircle_ProducesCircle + + - id: Rendering-Svg-SvgRenderer-RenderBand + title: >- + SvgRenderer shall render band nodes as rect elements. + justification: | + Bands group diagram contents and must appear as SVG rectangles. + tests: + - SvgRenderer_Render_SingleBand_ProducesRect + + - id: Rendering-Svg-SvgRenderer-RenderLifeline + title: >- + SvgRenderer shall render lifeline nodes with a header rect and stem line. + justification: | + Sequence-diagram lifelines require both the participant header and the vertical stem. + tests: + - SvgRenderer_Render_SingleLifeline_ProducesRectAndLine + + - id: Rendering-Svg-SvgRenderer-RenderActivation + title: >- + SvgRenderer shall render activation nodes as rect elements. + justification: | + Activation bars mark active intervals on lifelines and must appear as narrow rectangles. + tests: + - SvgRenderer_Render_SingleActivation_ProducesRect + + - id: Rendering-Svg-SvgRenderer-RenderGrid + title: >- + SvgRenderer shall render grid cells as rect elements. + justification: | + Tabular notation requires visible SVG cell boundaries. + tests: + - SvgRenderer_Render_SingleGrid_ProducesRects + + - id: Rendering-Svg-SvgRenderer-EndMarkers + title: >- + SvgRenderer shall define the open-chevron connector end marker as an open polyline. + justification: | + An open chevron expresses a distinct connector end shape and must not be closed as a + polygon. + tests: + - OpenChevron_IsDefinedAsPolyline + + - id: Rendering-Svg-SvgRenderer-EndMarkersOpenChevronReference + title: >- + SvgRenderer shall reference the open-chevron marker from open-chevron connector ends. + justification: | + Referencing the marker from the connector path attaches the open-chevron decoration to the + rendered line. + tests: + - OpenChevronLine_ReferencesOpenChevronMarker + + - id: Rendering-Svg-SvgRenderer-HollowTriangleEndMarkers + title: >- + SvgRenderer shall define the hollow-triangle connector end marker as a closed polygon. + justification: | + Hollow-triangle markers express relationship direction and must remain closed shapes. + tests: + - HollowTriangle_IsDefinedAsClosedPolygon + + - id: Rendering-Svg-SvgRenderer-HollowTriangleEndMarkerReference + title: >- + SvgRenderer shall emit a marker-end attribute for hollow-triangle target connector ends. + justification: | + Referencing a marker-end attaches the hollow-triangle decoration to the rendered line. + tests: + - SvgRenderer_Render_SingleLine_WithHollowTriangleArrowhead_ProducesMarkerEnd + + - id: Rendering-Svg-SvgRenderer-TriangleEndMarkerMetrics + title: >- + SvgRenderer shall derive triangle marker dimensions from the shared notation metrics. + justification: | + Shared notation metrics keep SVG triangle markers geometrically consistent with raster + renderers. + tests: + - TriangleMarker_DimensionsDeriveFromNotationMetrics + + - id: Rendering-Svg-SvgRenderer-DiamondEndMarkers + title: >- + SvgRenderer shall derive diamond marker dimensions from the shared notation metrics. + justification: | + Shared notation metrics keep SVG diamond markers geometrically consistent with raster + renderers. + tests: + - DiamondMarker_DimensionsDeriveFromNotationMetrics + + - id: Rendering-Svg-SvgRenderer-DiamondEndMarkerReference + title: >- + SvgRenderer shall include the hollow-diamond marker id when rendering a hollow-diamond line + end. + justification: | + The hollow-diamond marker id identifies the connector-end decoration used for diamond line + ends. + tests: + - SvgRenderer_Render_SingleLine_WithDiamondArrowhead_ProducesDiamondMarker + + - id: Rendering-Svg-SvgRenderer-CrossbarEndMarkers + title: >- + SvgRenderer shall include the hollow-triangle-crossbar marker id when rendering a crossbar + line end. + justification: | + The crossbar marker id identifies the connector-end decoration used for crossbar line ends. + tests: + - SvgRenderer_Render_SingleLine_WithOpenCrossbarArrowhead_ProducesOpenCrossbarMarker diff --git a/docs/reqstream/rendering/demo.yaml b/docs/reqstream/rendering/demo.yaml deleted file mode 100644 index 4900ff5..0000000 --- a/docs/reqstream/rendering/demo.yaml +++ /dev/null @@ -1,76 +0,0 @@ ---- -# Software Unit Requirements for Demo -# -# These requirements describe the design-level behavior of the Demo unit. - -sections: - - title: Demo Unit - requirements: - - id: Template-Demo-Greeting - title: The Demo class shall return a greeting in the format "{prefix}, {name}!". - justification: | - The greeting format is the core contract of the Demo unit: combining the - stored prefix with the caller-supplied name in a predictable, testable format. - tests: - - Demo_DemoMethod_DefaultPrefix_ReturnsGreeting - - Demo_DemoMethod_CustomPrefix_ReturnsGreeting - - - id: Template-Demo-DefaultPrefix - title: The Demo class shall use "Hello" as the default greeting prefix. - justification: | - Callers constructing Demo without a prefix argument need a stable, well-known default - they can rely on in documentation examples and integration code without reading source. - tests: - - Demo_DefaultPrefix_Read_IsHello - - Demo_DefaultConstructor_WithNoArgs_SetsDefaultPrefix - - - id: Template-Demo-AcceptCustomPrefix - title: The Demo class shall accept a caller-specified prefix at construction time. - justification: | - Callers who need a non-default greeting style must be able to supply their own prefix - at construction time; storing it enables all subsequent greeting calls to use it. - tests: - - Demo_Prefix_WithCustomConstruction_ReturnsCustomPrefix - - - id: Template-Demo-Prefix - title: The Demo class shall allow callers to read the configured greeting prefix. - justification: | - Callers may need to inspect which prefix a Demo instance was configured with, - for example to display it in logs or validate it against their own configuration. - tests: - - Demo_Prefix_WithCustomConstruction_ReturnsCustomPrefix - - Demo_DefaultConstructor_WithNoArgs_SetsDefaultPrefix - - - id: Template-Demo-ValidationNull-DemoMethod - title: The Demo class shall reject a null name argument to DemoMethod with ArgumentNullException. - justification: | - Null-safety is a fundamental contract: DemoMethod must throw ArgumentNullException - for a null name argument to immediately surface the programming error to callers. - tests: - - Demo_DemoMethod_NullInput_ThrowsArgumentNullException - - - id: Template-Demo-ValidationNull-Constructor - title: The Demo class shall reject a null prefix argument to the constructor with ArgumentNullException. - justification: | - Null-safety is a fundamental contract: the custom-prefix constructor must throw - ArgumentNullException for a null prefix argument to immediately surface the - programming error to callers. - tests: - - Demo_Constructor_NullPrefix_ThrowsArgumentNullException - - - id: Template-Demo-ValidationEmpty-DemoMethod - title: The Demo class shall reject an empty-string name argument to DemoMethod with ArgumentException. - justification: | - Non-empty validation is a fundamental contract: DemoMethod must throw ArgumentException - for an empty-string name argument to prevent malformed output such as "Hello, !". - tests: - - Demo_DemoMethod_EmptyInput_ThrowsArgumentException - - - id: Template-Demo-ValidationEmpty-Constructor - title: The Demo class shall reject an empty-string prefix argument to the constructor with ArgumentException. - justification: | - Non-empty validation is a fundamental contract: the custom-prefix constructor must throw - ArgumentException for an empty-string prefix argument to prevent malformed output - such as ", World!". - tests: - - Demo_Constructor_EmptyPrefix_ThrowsArgumentException diff --git a/docs/reqstream/rendering/layout-graph.yaml b/docs/reqstream/rendering/layout-graph.yaml new file mode 100644 index 0000000..93ddffa --- /dev/null +++ b/docs/reqstream/rendering/layout-graph.yaml @@ -0,0 +1,81 @@ +--- +# DemaConsulting.Rendering (Model) - Layout Graph Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the layout-graph unit: the unplaced, recursive input model +# (LayoutGraph, LayoutGraphNode, LayoutGraphEdge) that a layout algorithm consumes. +# - These unit requirements decompose the Rendering-Model-InputGraph system requirement declared +# in rendering.yaml. + +sections: + - title: Layout Graph Unit Requirements + requirements: + - id: Rendering-Model-LayoutGraph-AddNode + title: >- + Adding a node to the input graph shall append a node carrying the requested identity and + size and shall return that node. + justification: | + Returning the created node lets callers configure it further (label, per-element options) + immediately, while appending preserves the caller's insertion order for deterministic + layout. + tests: + - AddNode_AppendsNodeAndReturnsIt + + - id: Rendering-Model-LayoutGraph-AddEdge + title: >- + Adding an edge to the input graph shall append a directed edge referencing the supplied + source and target nodes and shall return that edge. + justification: | + Edges must reference their endpoint nodes so a layout algorithm can route connections; + returning the edge lets callers set its style and label. + tests: + - AddEdge_AppendsEdgeWithEndpoints + + - id: Rendering-Model-LayoutGraph-PerElementProperties + title: >- + A graph node shall carry per-element property overrides that are readable through the + property-holder contract. + justification: | + Per-element overrides let a caller tune the layout of an individual node without affecting + the rest of the graph, supporting the open configuration model. + tests: + - Node_CarriesPerElementProperties + + - id: Rendering-Model-LayoutGraph-ContainerNodes + title: >- + A graph node shall be able to contain a nested child subgraph of nodes and edges, and shall + report whether it currently acts as such a container. + justification: | + A hierarchical input model lets a caller describe compound elements — a package holding its + members, a block holding its parts — as a node that in turn contains its own graph, so a + layout engine can place nested content recursively. Reporting container status lets + consumers distinguish a container from a leaf and skip empty containers. + tests: + - LayoutGraphNode_Children_ContainerNode_HoldsChildNodesAndEdges + - LayoutGraphNode_HasChildren_LeafNode_ReturnsFalse + + - id: Rendering-Model-LayoutGraph-ScopedIdentifiers + title: >- + Node and edge identifiers shall be unique within each container scope and may be reused + across different scopes. + justification: | + Scoping identifiers to a container lets each nested subgraph be described independently + without a caller having to invent globally-unique names, while still guaranteeing that a + layout engine can address every element unambiguously within its own scope. + tests: + - LayoutGraph_AddNode_ChildScope_AllowsIdReuseAcrossScopes + - LayoutGraph_AddNode_ChildScope_DuplicateId_ThrowsArgumentException + - LayoutGraph_AddEdge_ChildScope_AllowsEdgeIdReuseAcrossScopes + + - id: Rendering-Model-LayoutGraph-CrossContainerEdge + title: >- + An edge held in a container shall be able to reference nodes that reside in different + descendant containers, so that connections spanning container boundaries are expressible. + justification: | + Real diagrams connect elements that live in different groups; allowing an edge in an ancestor + container to reference descendant endpoints lets a caller express those cross-boundary + connections without a distinct edge type, so a layout engine can route them at the lowest + common ancestor. + tests: + - LayoutGraphEdge_CrossContainer_ReferencingDescendant_ConstructibleAtRoot + - LayoutGraphEdge_CrossContainer_BetweenSiblingContainers_ConstructibleAtRoot diff --git a/docs/reqstream/rendering/layout-tree.yaml b/docs/reqstream/rendering/layout-tree.yaml new file mode 100644 index 0000000..684482e --- /dev/null +++ b/docs/reqstream/rendering/layout-tree.yaml @@ -0,0 +1,146 @@ +--- +# DemaConsulting.Rendering (Model) - Layout Tree Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the layout-tree unit: the placed, renderer-agnostic +# intermediate representation (LayoutTree and the LayoutNode discriminated-union of concrete +# node records, plus the shared Point2D / Rect geometry value types). +# - These unit requirements decompose the Rendering-Model-LayoutTree system requirement declared +# in rendering.yaml. + +sections: + - title: Layout Tree Unit Requirements + requirements: + - id: Rendering-Model-LayoutTree-Canvas + title: >- + The layout tree shall carry the canvas width, the canvas height, and the ordered list of + top-level nodes that make up a rendered view. + justification: | + A renderer needs the overall canvas size to establish the drawing surface and needs the + ordered node list to draw every element of the view. + tests: + - LayoutTree_Construction_StoresWidthHeightNodes + + - id: Rendering-Model-LayoutTree-AbsoluteCoordinates + title: >- + Every positioned node shall carry absolute coordinates that are preserved without any + further transformation being applied by the model. + justification: | + Absolute coordinates let a renderer draw each element directly from the tree without + resolving nested offsets, which keeps renderers simple and their output deterministic. + tests: + - LayoutBox_Coordinates_AreAbsolute + - LayoutPort_Coordinates_AreAbsolute + - LayoutLine_Waypoints_AreAbsolute + + - id: Rendering-Model-LayoutTree-Box + title: >- + A box node shall carry its position, size, optional label, nesting depth, shape, ordered + compartments, and nested child nodes. + justification: | + Boxes are the primary container element; carrying compartments and children lets the + model represent nested definitions and usages that renderers draw recursively. + tests: + - LayoutBox_Construction_StoresAllFields + - LayoutBox_Children_ContainsNestedNodes + + - id: Rendering-Model-LayoutTree-DepthNotColor + title: >- + A box node shall express its visual nesting as an integer depth rather than a resolved + color. + justification: | + Storing depth rather than a color keeps the model free of theme decisions, so the theme + in effect at render time selects the fill color for each nesting level. + tests: + - LayoutBox_Depth_IsInteger + + - id: Rendering-Model-LayoutTree-Port + title: >- + A port node shall carry its absolute centre, the box edge it is attached to, and an + optional label. + justification: | + Ports pin connection points to box edges; carrying the attached side lets a renderer + offset the port symbol and its label away from the box face correctly. + tests: + - LayoutPort_Construction_StoresAllFields + + - id: Rendering-Model-LayoutTree-Line + title: >- + A connector line node shall carry its ordered waypoints, its source and target end-marker + styles, its stroke style, and an optional midpoint label. + justification: | + Connectors express relationships; carrying pre-routed waypoints, end markers, and stroke + style lets a renderer draw the connector and its decorations without re-routing. + tests: + - LayoutLine_Construction_StoresAllFields + + - id: Rendering-Model-LayoutTree-Label + title: >- + A label node shall carry its position, maximum width, text, alignment, font weight, font + style, and font size. + justification: | + Standalone labels carry names and annotations; carrying explicit font styling and a + maximum width lets a renderer emphasize and fit text consistently. + tests: + - LayoutLabel_Construction_StoresAllFields + + - id: Rendering-Model-LayoutTree-Badge + title: >- + A badge node shall carry its centre, size, shape, and an optional label. + justification: | + Badges are small notation icons; carrying shape and size lets a renderer draw the correct + icon at the correct scale beside its optional label. + tests: + - LayoutBadge_Construction_StoresAllFields + + - id: Rendering-Model-LayoutTree-Band + title: >- + A band node shall carry its bounds, orientation, an optional label, and nested child + nodes. + justification: | + Swim-lane bands group elements; carrying orientation and children lets a renderer draw the + lane and its contents in the correct axis. + tests: + - LayoutBand_Construction_StoresAllFields + + - id: Rendering-Model-LayoutTree-Lifeline + title: >- + A lifeline node shall carry its centre, its top and bottom extent, its header label, and + its header dimensions. + justification: | + Sequence-diagram lifelines are a header box over a vertical stem; carrying both the extent + and the header size lets a renderer draw the header and dashed stem consistently. + tests: + - LayoutLifeline_Construction_StoresAllFields + + - id: Rendering-Model-LayoutTree-Activation + title: >- + An activation node shall carry its centre and its top and bottom extent. + justification: | + An activation bar marks when a lifeline is active; carrying its centre and extent lets a + renderer draw the narrow bar over the lifeline stem. + tests: + - LayoutActivation_Construction_StoresAllFields + + - id: Rendering-Model-LayoutTree-Grid + title: >- + A grid node shall carry its position and its ordered rows, each row flagged as header or + body and each cell carrying its size, text, alignment, and column span. + justification: | + Tabular notation (for example requirement tables) needs per-row header styling and + per-cell spans so a renderer can draw the table faithfully. + tests: + - LayoutGrid_Construction_StoresAllFields + + - id: Rendering-Model-LayoutTree-Geometry + title: >- + The layout tree unit shall provide shared immutable point and axis-aligned rectangle value + types, in logical pixels, that positioned nodes and layout algorithms use to express + coordinates and bounds. + justification: | + A single Point2D and Rect value type gives every node record and every layout algorithm one + consistent, allocation-light geometry vocabulary, so coordinates and bounds are expressed + and compared the same way throughout the model. + tests: + - Point2D_Construction_StoresXY + - Rect_Construction_StoresAllFields diff --git a/docs/reqstream/rendering/options.yaml b/docs/reqstream/rendering/options.yaml new file mode 100644 index 0000000..66b426d --- /dev/null +++ b/docs/reqstream/rendering/options.yaml @@ -0,0 +1,51 @@ +--- +# DemaConsulting.Rendering (Model) - Options Unit Requirements +# +# PURPOSE: +# - Unit-level requirements for the options unit: the open, ELK-inspired property-based +# configuration system (LayoutProperty, IPropertyHolder, PropertyHolder, LayoutOptions, +# CoreOptions, and the option enums). +# - These unit requirements decompose the Rendering-Model-Configuration system requirement +# declared in rendering.yaml. + +sections: + - title: Options Unit Requirements + requirements: + - id: Rendering-Model-Options-Default + title: >- + Reading a property that has not been set on a holder shall return that property's declared + default value. + justification: | + Declared defaults let algorithms and renderers read any option unconditionally, without + each caller having to pre-populate every option first. + tests: + - Get_UnsetProperty_ReturnsDefault + + - id: Rendering-Model-Options-StoreAndRetrieve + title: >- + A value written for a property shall be returned by a subsequent read of that same + property. + justification: | + Storing and retrieving option values is the core of the configuration system: callers set + the options they care about and algorithms read them back reliably. + tests: + - Get_AfterSet_ReturnsStoredValue + + - id: Rendering-Model-Options-Contains + title: >- + A property holder shall report whether a given property has been explicitly set. + justification: | + Distinguishing an explicitly-set value from a default lets an algorithm honor an override + only when a caller actually supplied one. + tests: + - Contains_ReflectsExplicitSet + + - id: Rendering-Model-Options-TryGet + title: >- + An attempt to read an unset property shall report that no value was set and shall yield the + declared default. + justification: | + A combined test-and-read operation lets a caller branch on whether an override exists while + still receiving a usable default in a single call. + tests: + - TryGet_UnsetProperty_ReturnsFalseAndDefault diff --git a/docs/reqstream/rendering/platform-requirements.yaml b/docs/reqstream/rendering/platform-requirements.yaml index 4ea57be..3281f86 100644 --- a/docs/reqstream/rendering/platform-requirements.yaml +++ b/docs/reqstream/rendering/platform-requirements.yaml @@ -1,78 +1,68 @@ --- # System-Level Platform Requirements # -# These requirements specify the platforms and frameworks the library must support. -# They ensure the library works across the required execution environments. +# These requirements specify the platforms and frameworks the rendering library must support. +# Test links use a source-filter prefix (e.g. "windows@", "net8.0@") to restrict which test results +# count as evidence, so a platform requirement is only satisfied by a run on that platform or runtime. # -# Test links can include a source filter prefix (e.g. "windows@", "ubuntu@", "net8.0@") to restrict -# which test results count as evidence for a requirement. This is critical for platform and framework -# requirements - removing these filters invalidates the evidence-based proof. -# -# Source filter prefixes: -# windows@TestName - proves the test passed on a Windows platform -# ubuntu@TestName - proves the test passed on a Linux (Ubuntu) platform -# macos@TestName - proves the test passed on a macOS platform -# net8.0@TestName - proves the test passed under the .NET 8 runtime -# net9.0@TestName - proves the test passed under the .NET 9 runtime -# net10.0@TestName - proves the test passed under the .NET 10 runtime -# net481@TestName - proves the test passed under .NET Framework 4.8.1 (implements .NET Standard 2.0) +# Each platform requirement is traced to representative tests spanning the three managed layers of the +# stack - the model (property system), the layout engine (layered placement and routing), and a +# renderer (SVG document emission) - so a passing run on a given matrix cell demonstrates that the +# library builds and executes end to end on that platform/runtime, not merely that a single type loads. sections: - title: Platform Support requirements: - - id: Template-Platform-Windows - title: The library shall build and run on Windows platforms. + - id: Rendering-Platform-Windows + title: The rendering library shall build and run on Windows platforms. justification: | DEMA Consulting libraries must support Windows as a major development platform. tests: - # Tests link to "windows" to ensure results come from Windows platform - - "windows@Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting" + - "windows@Get_AfterSet_ReturnsStoredValue" + - "windows@Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges" + - "windows@SvgRenderer_Render_SingleBox_ProducesSvgDocument" - - id: Template-Platform-Linux - title: The library shall build and run on Linux platforms. + - id: Rendering-Platform-Linux + title: The rendering library shall build and run on Linux platforms. justification: | DEMA Consulting libraries must support Linux for CI/CD and containerized environments. tests: - # Tests link to "ubuntu" to ensure results come from Linux platform - - "ubuntu@Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting" + - "ubuntu@Get_AfterSet_ReturnsStoredValue" + - "ubuntu@Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges" + - "ubuntu@SvgRenderer_Render_SingleBox_ProducesSvgDocument" - - id: Template-Platform-MacOS - title: The library shall build and run on macOS platforms. + - id: Rendering-Platform-MacOS + title: The rendering library shall build and run on macOS platforms. justification: | DEMA Consulting libraries must support macOS for developers using Apple platforms. tests: - # Tests link to "macos" to ensure results come from macOS platform - - "macos@Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting" + - "macos@Get_AfterSet_ReturnsStoredValue" + - "macos@Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges" + - "macos@SvgRenderer_Render_SingleBox_ProducesSvgDocument" - - id: Template-Platform-Net8 - title: The library shall support .NET 8 runtime. + - id: Rendering-Platform-Net8 + title: The rendering library shall support the .NET 8 runtime. justification: | .NET 8 is an LTS release providing long-term stability for enterprise users. tests: - - "net8.0@Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting" + - "net8.0@Get_AfterSet_ReturnsStoredValue" + - "net8.0@Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges" + - "net8.0@SvgRenderer_Render_SingleBox_ProducesSvgDocument" - - id: Template-Platform-Net9 - title: The library shall support .NET 9 runtime. + - id: Rendering-Platform-Net9 + title: The rendering library shall support the .NET 9 runtime. justification: | .NET 9 support enables users to leverage the latest .NET features. tests: - - "net9.0@Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting" + - "net9.0@Get_AfterSet_ReturnsStoredValue" + - "net9.0@Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges" + - "net9.0@SvgRenderer_Render_SingleBox_ProducesSvgDocument" - - id: Template-Platform-Net10 - title: The library shall support .NET 10 runtime. + - id: Rendering-Platform-Net10 + title: The rendering library shall support the .NET 10 runtime. justification: | .NET 10 support ensures the library remains compatible with the latest .NET ecosystem. tests: - - "net10.0@Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting" - - - id: Template-Platform-NetStd20 - title: The library shall support the .NET Standard 2.0 target framework. - justification: | - .NET Standard 2.0 is a widely-supported target framework that enables the library to - be used in MSBuild extensions and other tooling that requires .NET Standard compatibility. - Supporting this target framework ensures the library can be integrated into a broader - range of .NET projects, including those targeting .NET Framework and older .NET Core versions. - Because .NET Framework 4.8.1 implements .NET Standard 2.0, a passing test on net481 - demonstrates .NET Standard 2.0 compatibility. - tests: - - "net481@Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting" + - "net10.0@Get_AfterSet_ReturnsStoredValue" + - "net10.0@Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges" + - "net10.0@SvgRenderer_Render_SingleBox_ProducesSvgDocument" diff --git a/docs/reqstream/rendering/rendering.yaml b/docs/reqstream/rendering/rendering.yaml index a78ebf5..d255b5a 100644 --- a/docs/reqstream/rendering/rendering.yaml +++ b/docs/reqstream/rendering/rendering.yaml @@ -1,103 +1,90 @@ --- -# Software System Requirements for Rendering +# DemaConsulting.Rendering (Model) Requirements # -# These requirements describe the externally visible behavior of the library -# from the perspective of its consumers, without specifying implementation details. +# PURPOSE: +# - Define the requirements for the DemaConsulting.Rendering system: the SysML-agnostic +# rendering model that provides the placed layout-tree intermediate representation, the +# open (ELK-inspired) property-based option system, and the unplaced input layout graph. +# - The first section holds system-level requirements. Each system requirement decomposes +# downward (children) into the unit requirements that provide the detailed behavior. +# - The remaining sections hold the unit requirements for the three units of this system: +# layout-tree (the IR records), options (the property system), and layout-graph (the input model). +# +# SCOPE EXCLUSIONS: +# - The following public members are optional presentation or diagnostic metadata carried through +# unchanged by the model. They carry no algorithmic behavior of their own, and the rendered +# behavior they influence is specified and verified in the +# renderer systems (DemaConsulting.Rendering.Svg / DemaConsulting.Rendering.Skia), not in the +# model. They are therefore intentionally excluded from the Rendering-Model requirement set and +# are not given individual functional requirements: +# - LayoutBox.Keyword - optional SysML keyword text drawn above the box label +# - LayoutTree.Warnings - non-fatal layout-quality diagnostics (advisory only) +# - LayoutGraphNode.Label - optional input-node display text +# - LayoutGraphEdge.Label - optional input-edge midpoint display text +# - LayoutGraphEdge.TargetEnd - optional input-edge end-marker style hint +# - LayoutGraphEdge.LineStyle - optional input-edge stroke-style hint sections: - - title: Library API + - title: Rendering Model System Requirements requirements: - - id: Template-Lib-Greeting - title: The library shall generate a greeting string when given a name. - justification: | - Provides a simple demonstration of library functionality following - .NET library best practices for DEMA Consulting libraries. - children: - - Template-Demo-Greeting - tests: - - Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting - - Rendering_SystemCustomization_CustomPrefix_ReturnsExpectedGreeting - - - id: Template-Lib-GreetingFormat - title: The library shall return a greeting string in the format "{prefix}, {name}!". - justification: | - The format is the core observable contract of the library: callers depend on this - specific structure in documentation examples and integration code. - children: - - Template-Demo-Greeting - tests: - - Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting - - Rendering_SystemCustomization_CustomPrefix_ReturnsExpectedGreeting - - - id: Template-Lib-DefaultPrefix - title: The library shall use "Hello" as the default greeting prefix. - justification: | - Callers constructing the library without a prefix argument need a predictable default - greeting to rely on in documentation and examples. - children: - - Template-Demo-DefaultPrefix - tests: - - Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting - - - id: Template-Lib-CustomPrefix - title: The library shall support a customizable greeting prefix. - justification: | - Consumers may need to produce greetings with a prefix other than the - default, so the library must allow the prefix to be specified at - construction time. - children: - - Template-Demo-AcceptCustomPrefix - - Template-Demo-Prefix - tests: - - Rendering_SystemCustomization_CustomPrefix_ReturnsExpectedGreeting - - - id: Template-Lib-Prefix - title: The library shall expose the configured greeting prefix via a read-only property. - justification: | - Callers may need to inspect the prefix configured at construction time without - re-specifying it or relying on the greeting output format to infer it. - children: - - Template-Demo-Prefix - tests: - - Rendering_SystemIntegration_CustomPrefix_ExposesPrefix - - - id: Template-Lib-ValidationNull-DemoMethod - title: The library shall reject a null name argument to DemoMethod with ArgumentNullException. - justification: | - Passing null to DemoMethod is a programming error. Throwing ArgumentNullException - immediately surfaces the mistake to callers and prevents obscure failures deeper in - the call stack. - children: - - Template-Demo-ValidationNull-DemoMethod - tests: - - Rendering_SystemValidation_DemoMethodNullInput_ThrowsArgumentNullException - - - id: Template-Lib-ValidationNull-Constructor - title: The library shall reject a null prefix argument to the constructor with ArgumentNullException. + - id: Rendering-Model-LayoutTree + title: >- + The rendering model shall provide a placed layout-tree representation that carries every + diagram element type with absolute coordinates ready for a renderer to draw. justification: | - Passing null to the constructor is a programming error. Throwing ArgumentNullException - immediately surfaces the mistake and prevents invalid instances from being created. + Separating a placed, renderer-agnostic intermediate representation from the layout + algorithms and the renderers lets a single layout be drawn to any output format (SVG, + PNG) without re-computing geometry, and lets new element types be added additively. children: - - Template-Demo-ValidationNull-Constructor + - Rendering-Model-LayoutTree-Canvas + - Rendering-Model-LayoutTree-AbsoluteCoordinates + - Rendering-Model-LayoutTree-Box + - Rendering-Model-LayoutTree-DepthNotColor + - Rendering-Model-LayoutTree-Port + - Rendering-Model-LayoutTree-Line + - Rendering-Model-LayoutTree-Label + - Rendering-Model-LayoutTree-Badge + - Rendering-Model-LayoutTree-Band + - Rendering-Model-LayoutTree-Lifeline + - Rendering-Model-LayoutTree-Activation + - Rendering-Model-LayoutTree-Grid + - Rendering-Model-LayoutTree-Geometry tests: - - Rendering_SystemValidation_ConstructorNullPrefix_ThrowsArgumentNullException + - LayoutTree_Construction_StoresWidthHeightNodes + - LayoutBox_Construction_StoresAllFields - - id: Template-Lib-ValidationEmpty-DemoMethod - title: The library shall reject an empty-string name argument to DemoMethod with ArgumentException. + - id: Rendering-Model-Configuration + title: >- + The rendering model shall provide an open, strongly-typed configuration system that + carries options at graph, element, and standalone scope without changing any contract. justification: | - Passing an empty string to DemoMethod is a programming error. Throwing ArgumentException - immediately surfaces the mistake and prevents malformed output such as "Hello, !". + An ELK-inspired open property system lets configuration coverage grow purely additively: + new options are introduced by declaring new keys, so the layout and rendering contracts + never change signature and unknown options are carried harmlessly. children: - - Template-Demo-ValidationEmpty-DemoMethod + - Rendering-Model-Options-Default + - Rendering-Model-Options-StoreAndRetrieve + - Rendering-Model-Options-Contains + - Rendering-Model-Options-TryGet tests: - - Rendering_SystemValidation_DemoMethodEmptyInput_ThrowsArgumentException + - Get_UnsetProperty_ReturnsDefault + - Get_AfterSet_ReturnsStoredValue - - id: Template-Lib-ValidationEmpty-Constructor - title: The library shall reject an empty-string prefix argument to the constructor with ArgumentException. + - id: Rendering-Model-InputGraph + title: >- + The rendering model shall provide an unplaced input-graph representation of sized nodes + and directed edges that a layout algorithm consumes to produce a placed layout tree. justification: | - Passing an empty string to the constructor is a programming error. Throwing ArgumentException - immediately surfaces the mistake and prevents malformed output such as ", World!". + A distinct input model lets callers describe what is to be laid out (boxes and their + connections) independently of how any algorithm places them, and lets configuration be + attached to the whole graph or to individual elements. children: - - Template-Demo-ValidationEmpty-Constructor + - Rendering-Model-LayoutGraph-AddNode + - Rendering-Model-LayoutGraph-AddEdge + - Rendering-Model-LayoutGraph-PerElementProperties + - Rendering-Model-LayoutGraph-ContainerNodes + - Rendering-Model-LayoutGraph-ScopedIdentifiers + - Rendering-Model-LayoutGraph-CrossContainerEdge tests: - - Rendering_SystemValidation_ConstructorEmptyPrefix_ThrowsArgumentException + - AddNode_AppendsNodeAndReturnsIt + - AddEdge_AppendsEdgeWithEndpoints diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 3223543..8c56c13 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -2,176 +2,380 @@ ## Purpose -This document is the user guide for the Rendering, a demonstration project that -showcases best practices for DEMA Consulting DotNet Libraries. +This document is the user guide for the Rendering libraries, a set of general-purpose .NET +packages for laying out and rendering node-and-edge diagrams. The design is inspired by the +[Eclipse Layout Kernel (ELK)](https://eclipse.dev/elk/): you describe a diagram as a graph, +a pluggable algorithm places it, and a pluggable renderer draws it — all configured through an +open, extensible property system. ## Scope This user guide covers: -- Installation of the library -- Basic usage and examples -- API reference +- Installation of the packages +- The core concepts (graph, options, algorithm, layout tree, renderer) +- Basic usage: laying out a graph and rendering it to SVG, PNG, JPEG, or WEBP +- Configuring layout through the open property system +- Extending the library with custom algorithms and renderers # Continuous Compliance -This template follows the -[Continuous Compliance](https://github.com/demaconsulting/ContinuousCompliance) methodology, which ensures -compliance evidence is generated automatically on every CI run. - -## Key Practices - -- **Requirements Traceability**: Every requirement is linked to passing tests, and a trace matrix is - auto-generated on each release -- **Linting Enforcement**: markdownlint, cspell, and yamllint are enforced before any build proceeds -- **Automated Audit Documentation**: Each release ships with generated requirements, justifications, - trace matrix, and quality reports -- **CodeQL and SonarCloud**: Security and quality analysis runs on every build +This project follows the +[Continuous Compliance](https://github.com/demaconsulting/ContinuousCompliance) methodology, so +compliance evidence — requirements, justifications, a trace matrix, and quality reports — is +generated automatically on every CI run, and every requirement is linked to passing tests. # Installation -Install the library using the .NET CLI: +The library is split into focused packages so consumers take only what they need: + +| Package | When to install | +| --- | --- | +| `DemaConsulting.Rendering` | Always — the layout model, property system, and input graph | +| `DemaConsulting.Rendering.Abstractions` | Always — the algorithm/renderer contracts and registries | +| `DemaConsulting.Rendering.Layout` | To run the bundled algorithms and the `LayoutEngine` facade | +| `DemaConsulting.Rendering.Svg` | To render diagrams to SVG (no native dependencies) | +| `DemaConsulting.Rendering.Skia` | To render diagrams to PNG, JPEG, or WEBP (uses SkiaSharp) | ```bash -dotnet add package DemaConsulting.Rendering +dotnet add package DemaConsulting.Rendering.Layout +dotnet add package DemaConsulting.Rendering.Svg ``` +Installing a renderer or the layout package transitively brings in the model and abstractions. + +# Core Concepts + +- **`LayoutGraph`** — the *unplaced* input: sized `LayoutGraphNode` boxes and directed + `LayoutGraphEdge` connections. The graph is recursive — a node may contain a nested child subgraph + through its `Children` — so hierarchical (grouped) diagrams are expressible (see below). +- **`LayoutOptions`** — an open, property-keyed configuration bag. Options are declared as typed + `LayoutProperty` keys (see `CoreOptions`) and can be attached to the whole graph, a single + 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"`). +- **`LayoutEngine`** — the batteries-included facade (in `DemaConsulting.Rendering.Layout`). One call, + `LayoutEngine.Layout(graph, options)`, lays out a graph with the algorithm it declares, resolved from + the bundled algorithms. It handles both flat and nested graphs (see *Quickstart* below). +- **`LayoutTree`** — the *placed* result: boxes with absolute coordinates and orthogonally routed + connectors. +- **`IRenderer`** — draws a `LayoutTree` to a stream. Bundled implementations are `SvgRenderer` + and the SkiaSharp `PngRenderer`, `JpegRenderer`, and `WebpRenderer`. + # Usage -## Basic Usage +## Quickstart + +The fastest path from a graph to a rendered diagram is the `LayoutEngine` facade: describe the graph, +lay it out in one call, and render the placed tree. `LayoutEngine.Layout` lays out the graph with the +algorithm it declares and — when it declares none — defaults to the recursive `hierarchical` engine, so +the *same* call correctly handles both flat and nested graphs: ```csharp +using System.IO; using DemaConsulting.Rendering; - -var demo = new Demo(); -var result = demo.DemoMethod("World"); -Console.WriteLine(result); // Output: Hello, World! +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout; +using DemaConsulting.Rendering.Svg; + +// 1. Describe the diagram as a graph of sized boxes and directed edges. +var graph = new LayoutGraph(); +var a = graph.AddNode("a", width: 80, height: 40); +var b = graph.AddNode("b", width: 80, height: 40); +var c = graph.AddNode("c", width: 80, height: 40); +graph.AddEdge("a-b", a, b); +graph.AddEdge("b-c", b, c); + +// 2. Lay it out with whatever algorithm the graph declares (default: hierarchical). +var tree = LayoutEngine.Layout(graph, new LayoutOptions()); + +// 3. Render the placed tree to SVG. +var renderer = new SvgRenderer(); +using var output = File.Create("diagram.svg"); +renderer.Render(tree, new RenderOptions(Themes.Light), output); ``` -## API Reference +Swap `SvgRenderer` for `PngRenderer`, `JpegRenderer`, or `WebpRenderer` (from +`DemaConsulting.Rendering.Skia`) to produce a raster image instead; the rest of the flow is identical. -### Demo +For a flat graph the hierarchical default is **byte-for-byte identical** to the `layered` algorithm, so +the quickstart above lays the chain out exactly as the layered algorithm would — while a nested graph +(see *Describing nested (hierarchical) graphs* below) is composed automatically by the same call. -The `Demo` class provides demonstration functionality for the template library. +## Choosing a layout algorithm -#### Constants +Select an algorithm by setting `CoreOptions.Algorithm` on the options (or the graph); `LayoutEngine` +resolves it from the bundled algorithms. The two bundled *leaf* algorithms arrange a flat scope +differently: -##### DefaultPrefix +- **`"layered"`** arranges nodes by their *connectivity* into ELK-style Sugiyama layers with orthogonal + connectors — the convention for flow-like diagrams (block, state, activity). +- **`"containment"`** arranges nodes by their *reading order*, packing them into balanced rows and + routing each edge around the packed boxes — suited to peers grouped inside a container. ```csharp -public const string DefaultPrefix = "Hello"; +// Pick the layered algorithm explicitly. +var tree = LayoutEngine.Layout(graph, LayoutOptions.ForAlgorithm("layered")); + +// Or pack the same graph with the containment algorithm instead. +var packed = LayoutEngine.Layout(graph, LayoutOptions.ForAlgorithm("containment")); ``` -The greeting prefix used when no custom prefix is specified. +`LayoutOptions.ForAlgorithm("layered")` is shorthand for `new LayoutOptions()` with +`CoreOptions.Algorithm` set. A declaration on the graph itself takes precedence over one on the options, +so `graph.Set(CoreOptions.Algorithm, "containment")` selects containment even if the options say +otherwise. If you prefer to skip the facade you can still instantiate an algorithm directly and call +`Apply` — `new LayeredLayoutAlgorithm().Apply(graph, options)` — but `LayoutEngine` is the recommended +entry point because it also handles nesting. -#### Constructors +## Describing nested (hierarchical) graphs -##### Demo() +A `LayoutGraph` is recursive: any `LayoutGraphNode` can become a *container* by populating its +`Children` graph, so a diagram can nest groups within groups — a package holding its members, a block +holding its parts. This mirrors the ELK `ElkNode` model, where a node with children is a compound +node. The child subgraph is an ordinary `LayoutGraph`, so it offers the same `AddNode`/`AddEdge` API, +the same insertion order, and the same identifier-uniqueness — scoped to that container. Identifiers +therefore need only be unique *within their own scope* and may be reused across scopes. The `Children` +graph is created lazily, so leaf nodes cost nothing; use `HasChildren` to tell a container from a leaf. + +An edge that connects nodes in *different* containers needs no special type: add an ordinary edge to +the container at (or above) the **lowest common ancestor** of its endpoints — often the root graph — +and pass the descendant nodes as its source and target. ```csharp -public Demo() -``` +var graph = new LayoutGraph(); -Initializes a new instance of the `Demo` class with the default prefix "Hello". +// A container node and a peer leaf node at the root scope. +var group = graph.AddNode("group", width: 200, height: 120); +var outside = graph.AddNode("outside", width: 80, height: 40); -##### Demo(string prefix) +// Nested children live in the container's own identifier scope. +var inner1 = group.Children.AddNode("child1", width: 80, height: 40); +var inner2 = group.Children.AddNode("child2", width: 80, height: 40); +group.Children.AddEdge("inner-edge", inner1, inner2); // intra-container edge -```csharp -public Demo(string prefix) +// A cross-container edge lives at the lowest common ancestor (here the root) +// and references a descendant node inside the container. +graph.AddEdge("cross-edge", outside, inner1); + +bool groupIsContainer = group.HasChildren; // true +bool outsideIsContainer = outside.HasChildren; // false ``` -Initializes a new instance of the `Demo` class with a custom prefix. +Pass a graph like this to `LayoutEngine.Layout` (or the `HierarchicalLayoutAlgorithm` directly) and the +nesting is laid out for you — each container's children are placed in their own coordinate space and the +container is sized to enclose them (see *Laying out nested diagrams with the hierarchical engine* below). +The flat *leaf* algorithms (`LayeredLayoutAlgorithm`, `ContainmentLayoutAlgorithm`) read only the +top-level nodes and edges, so a flat graph lays out exactly as before and any nesting is carried +harmlessly until a hierarchical engine consumes it. -**Parameters:** +## Configuring layout with options -- `prefix` (string): The prefix to use in greetings. Must not be null or empty. +Options are set with typed keys from `CoreOptions`. Unknown or not-yet-honored options default +harmlessly, so it is always safe to set an option even if the chosen algorithm does not yet act +on it: -**Exceptions:** +```csharp +var options = new LayoutOptions(); +options.Set(CoreOptions.Algorithm, "layered"); +options.Set(CoreOptions.Direction, LayoutFlowDirection.Right); -- `ArgumentNullException`: Thrown when `prefix` is null. -- `ArgumentException`: Thrown when `prefix` is an empty string. +// Per-element overrides: any graph element is also a property holder. +a.Set(CoreOptions.NodeSpacing, 32.0); + +// Routing style rides the same property system, mirroring ELK's elk.edgeRouting. +options.Set(CoreOptions.EdgeRouting, EdgeRouting.Orthogonal); +``` -#### Properties +## Routing connectors among placed boxes -##### Prefix +When you have already positioned some boxes yourself — for example a free-form or containment layout +produced outside the layered algorithm — `ConnectorRouter` draws the connectors between them. Each +`Connection` names a source and target `LayoutBox`; the router picks anchors on the faces the two +boxes present to each other, steers around every other box, and returns one routed `LayoutLine` per +connection carrying your requested arrowhead, line style, and label. The routing style follows the +same `EdgeRouting` vocabulary as `CoreOptions.EdgeRouting` (today `Orthogonal`): ```csharp -public string Prefix { get; } -``` +// Three already-placed boxes; the connector from A to B must avoid M. +var a = new LayoutBox(0, 0, 80, 40, "A", 0, BoxShape.Rectangle, [], []); +var m = new LayoutBox(140, -10, 60, 80, "M", 0, BoxShape.Rectangle, [], []); +var b = new LayoutBox(260, 0, 80, 40, "B", 0, BoxShape.Rectangle, [], []); +var boxes = new[] { a, m, b }; -Gets the greeting prefix used by this instance. +var connections = new[] +{ + new Connection(a, b, EndMarkerStyle.FilledArrow), + new Connection(a, m, EndMarkerStyle.HollowDiamond, LineStyle.Dashed, "owns"), +}; + +// Default options route orthogonally with a 12px clearance; override Clearance to widen the gap. +var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); + +// Drop the placed boxes and routed connectors into a LayoutTree for any renderer. +var nodes = new List(); +nodes.AddRange(boxes); +nodes.AddRange(lines); +var tree = new LayoutTree(360, 80, nodes); +``` -#### Methods +## Packing boxes into a container -##### DemoMethod +When you want to arrange peer boxes inside a container — the members of a package, the contents of a +folder — and their reading order rather than their connectivity drives the layout, `ContainmentLayout` +packs them into rows within a width budget. It returns the same boxes repositioned to their packed +coordinates (relative to the region origin) plus the size of the container that encloses them, so you +can size the parent box to fit. Only each child's width and height affect placement; every other field +is carried through unchanged: ```csharp -public string DemoMethod(string name) -``` - -Returns a greeting message for the specified name. +// Three already-sized boxes to arrange inside a container. +var boxes = new[] +{ + new LayoutBox(0, 0, 80, 40, "A", 0, BoxShape.Rectangle, [], []), + new LayoutBox(0, 0, 120, 40, "B", 0, BoxShape.Rectangle, [], []), + new LayoutBox(0, 0, 60, 40, "C", 0, BoxShape.Rectangle, [], []), +}; -**Parameters:** +// Pack into rows no wider than 200px of content; gaps and padding use sensible defaults. +var result = ContainmentLayout.Pack(boxes, new ContainmentOptions(MaxContentWidth: 200)); -- `name` (string): The name to greet. Must not be null or empty. +// Wrap the packed children in a container sized to the returned region. +var container = new LayoutBox( + 0, 0, result.Width, result.Height, "Group", 0, BoxShape.Folder, [], result.Children); +``` -**Returns:** +## Laying out a graph with the containment algorithm -A string containing the greeting message in the format "{prefix}, {name}!". +When your diagram's elements group as peers whose reading order — not their connectivity — drives the +layout, select the bundled `ContainmentLayoutAlgorithm` (id `"containment"`) instead of the layered +algorithm. It packs the graph's top-level nodes into rows within a balanced content width, then routes +each edge around the packed boxes with the selected `EdgeRouting` style. Like the layered algorithm it +is deterministic and order-preserving, produces one placed box per node and one routed connector per +edge, and is selected the same way — so it is a drop-in alternative that changes no other code: -**Exceptions:** +```csharp +// Describe the diagram as a graph of peer boxes joined by a few edges. +var graph = new LayoutGraph(); +var a = graph.AddNode("a", width: 80, height: 40); +var b = graph.AddNode("b", width: 80, height: 40); +var c = graph.AddNode("c", width: 80, height: 40); +a.Label = "A"; +b.Label = "B"; +c.Label = "C"; +var ab = graph.AddEdge("a-b", a, b); +ab.TargetEnd = EndMarkerStyle.FilledArrow; +graph.AddEdge("a-c", a, c); + +// Pack the nodes into rows and route the edges around them. +var tree = LayoutEngine.Layout(graph, LayoutOptions.ForAlgorithm("containment")); + +// Render the placed tree with any renderer. +var renderer = new SvgRenderer(); +using var output = File.Create("containment.svg"); +renderer.Render(tree, new RenderOptions(Themes.Light), output); +``` -- `ArgumentNullException`: Thrown when `name` is null. -- `ArgumentException`: Thrown when `name` is an empty string. +## Laying out nested diagrams with the hierarchical engine -**Example:** +When your diagram nests groups within groups — a package holding its members, a block holding its +parts — select the bundled `HierarchicalLayoutAlgorithm` (id `"hierarchical"`) — which is also what +`LayoutEngine` uses by default. It is the recursive layout engine: it lays out each container's +`Children` in their own coordinate space, sizes the container to enclose them, and composes every +sub-layout into one placed tree. It does not place boxes itself; it delegates each scope to a *leaf* +algorithm chosen with `CoreOptions.Algorithm` — the root inherits the option's algorithm (default +`"layered"`), and any container can override it. A graph with no containers is laid out exactly as the +selected leaf algorithm would lay it out, so the engine is a safe drop-in. ```csharp -var demo = new Demo(); -var greeting = demo.DemoMethod("World"); -// greeting = "Hello, World!" +// A container laid out "layered", packed by a "containment" root, plus a sibling leaf. +var graph = new LayoutGraph(); +var group = graph.AddNode("group", width: 10, height: 10); +group.Label = "Group"; +group.Set(CoreOptions.Algorithm, "layered"); // this container lays its children out layered +var c1 = group.Children.AddNode("c1", width: 80, height: 40); +var c2 = group.Children.AddNode("c2", width: 80, height: 40); +group.Children.AddEdge("c1-c2", c1, c2); // intra-container edge +graph.AddNode("outside", width: 80, height: 40); // a sibling leaf at the root + +// Pack the root with the containment algorithm; the container recurses with its own algorithm. +var tree = LayoutEngine.Layout(graph, LayoutOptions.ForAlgorithm("containment")); + +// Render the composed tree with any renderer. +var renderer = new SvgRenderer(); +using var output = File.Create("nested.svg"); +renderer.Render(tree, new RenderOptions(Themes.Light), output); ``` -# Examples +## Selecting algorithms and renderers by registry -## Example 1: Basic Greeting +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: ```csharp -using DemaConsulting.Rendering; - -var demo = new Demo(); -var result = demo.DemoMethod("Alice"); -Console.WriteLine(result); -// Output: Hello, Alice! +// Shortcut: a registry with the three bundled algorithms already registered. +var algorithms = LayoutAlgorithms.CreateDefaultRegistry(); +algorithms.Register(new MyTreeLayoutAlgorithm()); // extend it with your own + +var renderers = new RendererRegistry(); +renderers.Register(new SvgRenderer()); +renderers.Register(new PngRenderer()); +renderers.Register(new JpegRenderer()); +renderers.Register(new WebpRenderer()); + +// Resolve and apply an algorithm by id (or hand the registry to LayoutEngine.Layout). +var algorithm = algorithms.Resolve(options.Get(CoreOptions.Algorithm)); // e.g. "layered" +var tree = algorithm.Apply(graph, options); + +// Resolve the renderer directly from the desired output filename's extension. +var outputPath = "diagram.webp"; +var renderer = renderers.ResolveByExtension(Path.GetExtension(outputPath)); +using var output = File.Create(outputPath); +renderer.Render(tree, new RenderOptions(Themes.Light), output); ``` -## Example 2: Custom Prefix +`LayoutEngine.Layout(graph, options, registry)` accepts a custom registry directly, so you can keep the +one-call convenience while resolving against your own set of algorithms. -```csharp -using DemaConsulting.Rendering; +# Gallery -var demo = new Demo("Hi"); -var result = demo.DemoMethod("Alice"); -Console.WriteLine(result); -// Output: Hi, Alice! +A browsable gallery (the `gallery.md` document under `docs/gallery/`) showcases what the library can +produce: a layered diagram, a containment-packed diagram, a hierarchical nested diagram with a +cross-container edge, orthogonal edge routing around an obstacle, the three built-in themes on one +diagram, and both the SVG and PNG output paths. Every image is generated by the +`DemaConsulting.Rendering.Gallery` test project directly from the public API, so the gallery doubles +as an end-to-end rendering smoke test that runs on every build. + +To regenerate the committed gallery under `docs/gallery/` — the images and the `gallery.md` index — +run the following from the repository root: + +```pwsh +pwsh ./gallery.ps1 ``` -## Example 3: Error Handling +The script points the `RENDERING_GALLERY_DIR` environment variable at `docs/gallery/` and runs only +the gallery project. On an ordinary build the same facts render to a throwaway directory and simply +assert that each image is valid, so the gallery never dirties the repository during normal testing. -```csharp -using DemaConsulting.Rendering; +# Extending the Library -var demo = new Demo(); -try -{ - var result = demo.DemoMethod(null); -} -catch (ArgumentNullException ex) -{ - Console.WriteLine("Name cannot be null"); -} -``` +The library is open for extension without modifying existing code: + +- **New layout algorithms** (for example tree, force, or packing layouts) implement + `ILayoutAlgorithm` and are registered under a new id. Consumers select them via + `CoreOptions.Algorithm`. +- **New output formats** implement `IRenderer` with a distinct `MediaType` and file extensions and + register alongside the bundled SVG, PNG, JPEG, and WEBP renderers. +- **New configuration options** are introduced by declaring additional `LayoutProperty` keys. + Because options travel in an open property bag, adding a key never changes an existing method + signature. # References - [REF-1] Continuous Compliance Methodology () +- [REF-2] Eclipse Layout Kernel () diff --git a/docs/verification/definition.yaml b/docs/verification/definition.yaml index 27fac51..1568435 100644 --- a/docs/verification/definition.yaml +++ b/docs/verification/definition.yaml @@ -6,7 +6,10 @@ input-files: - docs/verification/title.txt - docs/verification/introduction.md - docs/verification/rendering/rendering.md - - docs/verification/rendering/demo.md + - docs/verification/rendering-abstractions/rendering-abstractions.md + - docs/verification/rendering-layout/rendering-layout.md + - docs/verification/rendering-svg/rendering-svg.md + - docs/verification/rendering-skia/rendering-skia.md - docs/verification/ots/buildmark.md - docs/verification/ots/fileassert.md - docs/verification/ots/pandoc.md diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index c06817d..0fb6195 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -1,15 +1,15 @@ # Introduction -This document provides the verification design for the Rendering, a .NET library -demonstrating best practices for DEMA Consulting DotNet Libraries. +This document provides the verification design for the Rendering libraries, a set of +general-purpose .NET packages for laying out and rendering node-and-edge diagrams. ## Purpose -The purpose of this document is to serve as the verification design entry point and document how -requirements will be tested across all software items in the Rendering system. This -documentation enables formal review by mapping every requirement to named test scenarios, supports -compliance auditing by providing clear traceability from requirements through verification design -to tests, and ensures test completeness can be assessed without reading implementation code. +The purpose of this document is to serve as the verification design entry point and to document +how requirements will be tested across all software items in the five Rendering systems. It +enables formal review by mapping every requirement to named test scenarios, supports compliance +auditing by providing traceability from requirements through verification design to tests, and +lets test completeness be assessed without reading implementation code. This document is intended for: @@ -20,11 +20,14 @@ This document is intended for: ## Scope -This document covers the verification design for the Rendering system and its -constituent software items, specifically: +This document covers the verification design for the five Rendering systems and their +constituent software items: -- **Rendering (System)** — The complete .NET library template system -- **Demo (Unit)** — Demonstration greeting class providing example functionality +- **Rendering (System)** — layout model: `LayoutTree` IR, property system, `LayoutGraph` +- **Rendering.Abstractions (System)** — SPI contracts, registries, theme, notation metrics +- **Rendering.Layout (System)** — layered pipeline engines and `LayeredLayoutAlgorithm` +- **Rendering.Svg (System)** — SVG renderer +- **Rendering.Skia (System)** — SkiaSharp raster renderers (PNG, JPEG, WEBP) The following OTS items are also covered: @@ -39,42 +42,29 @@ The following OTS items are also covered: - **WeasyPrint** — HTML-to-PDF conversion tool - **xUnit** — unit-testing framework -This verification documentation covers the same software items as the design documentation. - -Version applicability: This verification design applies to all versions of the Template DotNet -Library. - -The following topics are explicitly excluded from this verification documentation: +This verification documentation covers the same in-house software items as the design +documentation. The following topics are explicitly excluded: - Build pipeline and CI/CD process testing - Infrastructure and hosting environment testing -## Companion Artifact Structure +## Verification Approach -Each software item in the structure below has corresponding artifacts in parallel directory trees: - -```text -Rendering (System) -└── Demo (Unit) - -OTS Items -├── BuildMark -├── FileAssert -├── Pandoc -├── ReqStream -├── ReviewMark -├── SarifMark -├── SonarMark -├── VersionMark -├── WeasyPrint -└── xUnit -``` +Each software item is verified by xUnit v3 tests executed by `dotnet test` (invoked by +`build.ps1` and CI) across the supported target frameworks (.NET 8, 9, and 10). Because the +libraries are pure and deterministic, no mocking is required: tests supply controlled inputs +(graphs, layout trees, options) and assert on returned values, produced geometry, or rendered +output. The layered pipeline additionally carries byte-for-byte equivalence and legacy-oracle +tests that pin its numeric output. Requirement coverage is proven by an auto-generated trace +matrix linking each requirement to the passing tests named in its verification scenarios. + +## Companion Artifact Structure In-house items have artifacts in these parallel locations: -- Requirements: `docs/reqstream/{system}/.../{item}.yaml` (kebab-case) -- Design docs: `docs/design/{system}/.../{item}.md` (kebab-case) -- Verification design: `docs/verification/{system}/.../{item}.md` (kebab-case) +- 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) - 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/buildmark.md b/docs/verification/ots/buildmark.md index da5e4bf..ec49ced 100644 --- a/docs/verification/ots/buildmark.md +++ b/docs/verification/ots/buildmark.md @@ -31,7 +31,7 @@ BuildMark did not produce the required output. **Expected**: BuildMark exits without error and produces a non-empty markdown build-notes document in the release artifacts. -**Requirement coverage**: `Template-OTS-BuildMark`. +**Requirement coverage**: `Rendering-OTS-BuildMark`. ### BuildMark_GitIntegration @@ -39,7 +39,7 @@ in the release artifacts. **Expected**: Exits 0 and correctly reads version tags and commit history. -**Requirement coverage**: `Template-OTS-BuildMark`. +**Requirement coverage**: `Rendering-OTS-BuildMark`. ### BuildMark_IssueTracking @@ -47,7 +47,7 @@ in the release artifacts. **Expected**: Exits 0 and correctly fetches and processes mock issues and pull requests. -**Requirement coverage**: `Template-OTS-BuildMark`. +**Requirement coverage**: `Rendering-OTS-BuildMark`. ### BuildMark_KnownIssuesReporting @@ -55,7 +55,7 @@ in the release artifacts. **Expected**: Exits 0 and correctly includes known issues. -**Requirement coverage**: `Template-OTS-BuildMark`. +**Requirement coverage**: `Rendering-OTS-BuildMark`. ### BuildMark_RulesRouting @@ -64,9 +64,9 @@ type rules. **Expected**: Exits 0 and correctly routes items to the expected sections. -**Requirement coverage**: `Template-OTS-BuildMark`. +**Requirement coverage**: `Rendering-OTS-BuildMark`. ## Requirements Coverage -- **`Template-OTS-BuildMark`**: BuildMark_MarkdownReportGeneration, BuildMark_GitIntegration, +- **`Rendering-OTS-BuildMark`**: BuildMark_MarkdownReportGeneration, BuildMark_GitIntegration, BuildMark_IssueTracking, BuildMark_KnownIssuesReporting, BuildMark_RulesRouting diff --git a/docs/verification/ots/fileassert.md b/docs/verification/ots/fileassert.md index 4eda8ff..13de839 100644 --- a/docs/verification/ots/fileassert.md +++ b/docs/verification/ots/fileassert.md @@ -33,7 +33,7 @@ results containing both passing and failing outcomes. **Expected**: Writes a TRX results file with correctly classified pass and fail entries. -**Requirement coverage**: `Template-OTS-FileAssert`. +**Requirement coverage**: `Rendering-OTS-FileAssert`. ### FileAssert_Exists @@ -42,7 +42,7 @@ assert file existence. **Expected**: Passes when the specified file is present. -**Requirement coverage**: `Template-OTS-FileAssert`. +**Requirement coverage**: `Rendering-OTS-FileAssert`. ### FileAssert_Contains @@ -51,8 +51,8 @@ to verify file content. **Expected**: Passes when the specified content is present. -**Requirement coverage**: `Template-OTS-FileAssert`. +**Requirement coverage**: `Rendering-OTS-FileAssert`. ## Requirements Coverage -- **`Template-OTS-FileAssert`**: FileAssert_Results, FileAssert_Exists, FileAssert_Contains +- **`Rendering-OTS-FileAssert`**: FileAssert_Results, FileAssert_Exists, FileAssert_Contains diff --git a/docs/verification/ots/pandoc.md b/docs/verification/ots/pandoc.md index ca29411..f39a35d 100644 --- a/docs/verification/ots/pandoc.md +++ b/docs/verification/ots/pandoc.md @@ -24,7 +24,7 @@ element, and includes expected document content. **Expected**: FileAssert exits 0 for the build-notes HTML document. -**Requirement coverage**: `Template-OTS-Pandoc`. +**Requirement coverage**: `Rendering-OTS-Pandoc`. ### Pandoc_CodeQualityHtml @@ -33,7 +33,7 @@ element, and includes expected document content. **Expected**: FileAssert exits 0 for the code-quality HTML document. -**Requirement coverage**: `Template-OTS-Pandoc`. +**Requirement coverage**: `Rendering-OTS-Pandoc`. ### Pandoc_ReviewPlanHtml @@ -42,7 +42,7 @@ element, and includes expected document content. **Expected**: FileAssert exits 0 for the review plan HTML document. -**Requirement coverage**: `Template-OTS-Pandoc`. +**Requirement coverage**: `Rendering-OTS-Pandoc`. ### Pandoc_ReviewReportHtml @@ -51,7 +51,7 @@ element, and includes expected document content. **Expected**: FileAssert exits 0 for the review report HTML document. -**Requirement coverage**: `Template-OTS-Pandoc`. +**Requirement coverage**: `Rendering-OTS-Pandoc`. ### Pandoc_DesignHtml @@ -60,7 +60,7 @@ element, and includes expected document content. **Expected**: FileAssert exits 0 for the design document HTML. -**Requirement coverage**: `Template-OTS-Pandoc`. +**Requirement coverage**: `Rendering-OTS-Pandoc`. ### Pandoc_VerificationHtml @@ -69,7 +69,7 @@ element, and includes expected verification document content. **Expected**: FileAssert exits 0 for the verification document. -**Requirement coverage**: `Template-OTS-Pandoc`. +**Requirement coverage**: `Rendering-OTS-Pandoc`. ### Pandoc_UserGuideHtml @@ -78,9 +78,9 @@ element, and includes expected document content. **Expected**: FileAssert exits 0 for the user guide HTML document. -**Requirement coverage**: `Template-OTS-Pandoc`. +**Requirement coverage**: `Rendering-OTS-Pandoc`. ## Requirements Coverage -- **`Template-OTS-Pandoc`**: Pandoc_BuildNotesHtml, Pandoc_CodeQualityHtml, Pandoc_ReviewPlanHtml, +- **`Rendering-OTS-Pandoc`**: Pandoc_BuildNotesHtml, Pandoc_CodeQualityHtml, Pandoc_ReviewPlanHtml, Pandoc_ReviewReportHtml, Pandoc_DesignHtml, Pandoc_VerificationHtml, Pandoc_UserGuideHtml diff --git a/docs/verification/ots/reqstream.md b/docs/verification/ots/reqstream.md index b2af701..d5f9510 100644 --- a/docs/verification/ots/reqstream.md +++ b/docs/verification/ots/reqstream.md @@ -33,7 +33,7 @@ complete test coverage. **Expected**: Correctly loads and parses all requirements. -**Requirement coverage**: `Template-OTS-ReqStream`. +**Requirement coverage**: `Rendering-OTS-ReqStream`. ### ReqStream_TraceMatrix @@ -42,7 +42,7 @@ results. **Expected**: Produces a correctly linked trace matrix. -**Requirement coverage**: `Template-OTS-ReqStream`. +**Requirement coverage**: `Rendering-OTS-ReqStream`. ### ReqStream_ReportExport @@ -50,7 +50,7 @@ results. **Expected**: Produces a correctly formatted requirements report. -**Requirement coverage**: `Template-OTS-ReqStream`. +**Requirement coverage**: `Rendering-OTS-ReqStream`. ### ReqStream_TagsFiltering @@ -58,7 +58,7 @@ results. **Expected**: Returns only requirements matching the specified tags. -**Requirement coverage**: `Template-OTS-ReqStream`. +**Requirement coverage**: `Rendering-OTS-ReqStream`. ### ReqStream_EnforcementMode @@ -67,7 +67,7 @@ coverage. **Expected**: Exits 0; would exit non-zero if any requirement lacked coverage. -**Requirement coverage**: `Template-OTS-ReqStream`. +**Requirement coverage**: `Rendering-OTS-ReqStream`. ### ReqStream_Lint @@ -76,10 +76,10 @@ deliberate issues. **Expected**: Correctly identifies and reports the issues. -**Requirement coverage**: `Template-OTS-ReqStream-Lint`. +**Requirement coverage**: `Rendering-OTS-ReqStream-Lint`. ## Requirements Coverage -- **`Template-OTS-ReqStream`**: ReqStream_RequirementsProcessing, ReqStream_TraceMatrix, +- **`Rendering-OTS-ReqStream`**: ReqStream_RequirementsProcessing, ReqStream_TraceMatrix, ReqStream_ReportExport, ReqStream_TagsFiltering, ReqStream_EnforcementMode -- **`Template-OTS-ReqStream-Lint`**: ReqStream_Lint +- **`Rendering-OTS-ReqStream-Lint`**: ReqStream_Lint diff --git a/docs/verification/ots/reviewmark.md b/docs/verification/ots/reviewmark.md index 7a2b9a1..dc0855e 100644 --- a/docs/verification/ots/reviewmark.md +++ b/docs/verification/ots/reviewmark.md @@ -33,7 +33,7 @@ from a test configuration. **Expected**: Exits 0 and produces a non-empty review plan markdown file. -**Requirement coverage**: `Template-OTS-ReviewMark`. +**Requirement coverage**: `Rendering-OTS-ReviewMark`. ### ReviewMark_ReviewReportGeneration @@ -42,7 +42,7 @@ report from a test configuration and evidence store. **Expected**: Exits 0 and produces a non-empty review report. -**Requirement coverage**: `Template-OTS-ReviewMark`. +**Requirement coverage**: `Rendering-OTS-ReviewMark`. ### ReviewMark_IndexScan @@ -51,7 +51,7 @@ report from a test configuration and evidence store. **Expected**: Exits 0 and produces a correctly structured `index.json`. -**Requirement coverage**: `Template-OTS-ReviewMark-IndexScan`. +**Requirement coverage**: `Rendering-OTS-ReviewMark-IndexScan`. ### ReviewMark_WorkingDirectoryOverride @@ -60,7 +60,7 @@ operations. **Expected**: Exits 0 and resolves paths relative to the specified directory. -**Requirement coverage**: `Template-OTS-ReviewMark-DirectoryOverride`. +**Requirement coverage**: `Rendering-OTS-ReviewMark-DirectoryOverride`. ### ReviewMark_Enforce @@ -69,7 +69,7 @@ issues. **Expected**: Exits with a non-zero exit code when review issues are present. -**Requirement coverage**: `Template-OTS-ReviewMark-Enforce`. +**Requirement coverage**: `Rendering-OTS-ReviewMark-Enforce`. ### ReviewMark_Elaborate @@ -78,7 +78,7 @@ named review set. **Expected**: Exits 0 and prints the review-set ID, fingerprint, and file list. -**Requirement coverage**: `Template-OTS-ReviewMark-Elaborate`. +**Requirement coverage**: `Rendering-OTS-ReviewMark-Elaborate`. ### ReviewMark_Lint @@ -88,13 +88,13 @@ issues. **Expected**: Exits non-zero and outputs at least one reported issue identifying a structural or semantic error in the test definition that contains known errors. -**Requirement coverage**: `Template-OTS-ReviewMark-Lint`. +**Requirement coverage**: `Rendering-OTS-ReviewMark-Lint`. ## Requirements Coverage -- **`Template-OTS-ReviewMark`**: ReviewMark_ReviewPlanGeneration, ReviewMark_ReviewReportGeneration -- **`Template-OTS-ReviewMark-IndexScan`**: ReviewMark_IndexScan -- **`Template-OTS-ReviewMark-Enforce`**: ReviewMark_Enforce -- **`Template-OTS-ReviewMark-Elaborate`**: ReviewMark_Elaborate -- **`Template-OTS-ReviewMark-Lint`**: ReviewMark_Lint -- **`Template-OTS-ReviewMark-DirectoryOverride`**: ReviewMark_WorkingDirectoryOverride +- **`Rendering-OTS-ReviewMark`**: ReviewMark_ReviewPlanGeneration, ReviewMark_ReviewReportGeneration +- **`Rendering-OTS-ReviewMark-IndexScan`**: ReviewMark_IndexScan +- **`Rendering-OTS-ReviewMark-Enforce`**: ReviewMark_Enforce +- **`Rendering-OTS-ReviewMark-Elaborate`**: ReviewMark_Elaborate +- **`Rendering-OTS-ReviewMark-Lint`**: ReviewMark_Lint +- **`Rendering-OTS-ReviewMark-DirectoryOverride`**: ReviewMark_WorkingDirectoryOverride diff --git a/docs/verification/ots/sarifmark.md b/docs/verification/ots/sarifmark.md index 10d3fcc..9a350c1 100644 --- a/docs/verification/ots/sarifmark.md +++ b/docs/verification/ots/sarifmark.md @@ -32,7 +32,7 @@ not produce the required output. **Expected**: Exits 0 and successfully reads the SARIF content. -**Requirement coverage**: `Template-OTS-SarifMark`. +**Requirement coverage**: `Rendering-OTS-SarifMark`. ### SarifMark_MarkdownReportGeneration @@ -41,7 +41,7 @@ artifacts. **Expected**: Exits 0 and produces a non-empty markdown report. -**Requirement coverage**: `Template-OTS-SarifMark`. +**Requirement coverage**: `Rendering-OTS-SarifMark`. ### SarifMark_Enforcement @@ -50,9 +50,9 @@ enforcement mode. **Expected**: Returns a non-zero exit code when issues are found. -**Requirement coverage**: `Template-OTS-SarifMark-Enforcement`. +**Requirement coverage**: `Rendering-OTS-SarifMark-Enforcement`. ## Requirements Coverage -- **`Template-OTS-SarifMark`**: SarifMark_SarifReading, SarifMark_MarkdownReportGeneration -- **`Template-OTS-SarifMark-Enforcement`**: SarifMark_Enforcement +- **`Rendering-OTS-SarifMark`**: SarifMark_SarifReading, SarifMark_MarkdownReportGeneration +- **`Rendering-OTS-SarifMark-Enforcement`**: SarifMark_Enforcement diff --git a/docs/verification/ots/sonarmark.md b/docs/verification/ots/sonarmark.md index 57155a6..e57c0a0 100644 --- a/docs/verification/ots/sonarmark.md +++ b/docs/verification/ots/sonarmark.md @@ -32,7 +32,7 @@ not retrieve and render quality data correctly. **Expected**: Exits 0 and retrieves quality-gate data. -**Requirement coverage**: `Template-OTS-SonarMark`. +**Requirement coverage**: `Rendering-OTS-SonarMark`. ### SonarMark_IssuesRetrieval @@ -40,7 +40,7 @@ not retrieve and render quality data correctly. **Expected**: Exits 0 and retrieves issues data. -**Requirement coverage**: `Template-OTS-SonarMark`. +**Requirement coverage**: `Rendering-OTS-SonarMark`. ### SonarMark_HotSpotsRetrieval @@ -48,7 +48,7 @@ not retrieve and render quality data correctly. **Expected**: Exits 0 and retrieves hot-spots data. -**Requirement coverage**: `Template-OTS-SonarMark`. +**Requirement coverage**: `Rendering-OTS-SonarMark`. ### SonarMark_MarkdownReportGeneration @@ -56,9 +56,9 @@ not retrieve and render quality data correctly. **Expected**: Exits 0 and produces a non-empty markdown quality report. -**Requirement coverage**: `Template-OTS-SonarMark`. +**Requirement coverage**: `Rendering-OTS-SonarMark`. ## Requirements Coverage -- **`Template-OTS-SonarMark`**: SonarMark_QualityGateRetrieval, SonarMark_IssuesRetrieval, +- **`Rendering-OTS-SonarMark`**: SonarMark_QualityGateRetrieval, SonarMark_IssuesRetrieval, SonarMark_HotSpotsRetrieval, SonarMark_MarkdownReportGeneration diff --git a/docs/verification/ots/versionmark.md b/docs/verification/ots/versionmark.md index ad10f9d..0c758b4 100644 --- a/docs/verification/ots/versionmark.md +++ b/docs/verification/ots/versionmark.md @@ -30,7 +30,7 @@ at any step is evidence that VersionMark did not execute correctly. **Expected**: Exits 0 and captures version data for every tool. -**Requirement coverage**: `Template-OTS-VersionMark`. +**Requirement coverage**: `Rendering-OTS-VersionMark`. ### VersionMark_GeneratesMarkdownReport @@ -38,7 +38,7 @@ at any step is evidence that VersionMark did not execute correctly. **Expected**: Exits 0 and produces a non-empty versions markdown file. -**Requirement coverage**: `Template-OTS-VersionMark`. +**Requirement coverage**: `Rendering-OTS-VersionMark`. ### VersionMark_LintPassesForValidConfig @@ -47,7 +47,7 @@ config. **Expected**: Exits 0 and reports no errors. -**Requirement coverage**: `Template-OTS-VersionMark-Lint`. +**Requirement coverage**: `Rendering-OTS-VersionMark-Lint`. ### VersionMark_LintReportsErrorsForInvalidConfig @@ -56,10 +56,10 @@ deliberate errors. **Expected**: Correctly identifies and reports the configuration errors. -**Requirement coverage**: `Template-OTS-VersionMark-Lint`. +**Requirement coverage**: `Rendering-OTS-VersionMark-Lint`. ## Requirements Coverage -- **`Template-OTS-VersionMark`**: VersionMark_CapturesVersions, VersionMark_GeneratesMarkdownReport -- **`Template-OTS-VersionMark-Lint`**: VersionMark_LintPassesForValidConfig, +- **`Rendering-OTS-VersionMark`**: VersionMark_CapturesVersions, VersionMark_GeneratesMarkdownReport +- **`Rendering-OTS-VersionMark-Lint`**: VersionMark_LintPassesForValidConfig, VersionMark_LintReportsErrorsForInvalidConfig diff --git a/docs/verification/ots/weasyprint.md b/docs/verification/ots/weasyprint.md index a33a892..4d496ca 100644 --- a/docs/verification/ots/weasyprint.md +++ b/docs/verification/ots/weasyprint.md @@ -25,7 +25,7 @@ includes expected document content. **Expected**: FileAssert exits 0 for the build-notes PDF document. -**Requirement coverage**: `Template-OTS-WeasyPrint`. +**Requirement coverage**: `Rendering-OTS-WeasyPrint`. ### WeasyPrint_CodeQualityPdf @@ -34,7 +34,7 @@ includes expected document content. **Expected**: FileAssert exits 0 for the code-quality PDF document. -**Requirement coverage**: `Template-OTS-WeasyPrint`. +**Requirement coverage**: `Rendering-OTS-WeasyPrint`. ### WeasyPrint_ReviewPlanPdf @@ -43,7 +43,7 @@ includes expected document content. **Expected**: FileAssert exits 0 for the review plan PDF document. -**Requirement coverage**: `Template-OTS-WeasyPrint`. +**Requirement coverage**: `Rendering-OTS-WeasyPrint`. ### WeasyPrint_ReviewReportPdf @@ -52,7 +52,7 @@ includes expected document content. **Expected**: FileAssert exits 0 for the review report PDF document. -**Requirement coverage**: `Template-OTS-WeasyPrint`. +**Requirement coverage**: `Rendering-OTS-WeasyPrint`. ### WeasyPrint_DesignPdf @@ -61,7 +61,7 @@ and includes expected document content. **Expected**: FileAssert exits 0 for the design document PDF. -**Requirement coverage**: `Template-OTS-WeasyPrint`. +**Requirement coverage**: `Rendering-OTS-WeasyPrint`. ### WeasyPrint_VerificationPdf @@ -70,7 +70,7 @@ includes expected verification document content. **Expected**: FileAssert exits 0 for the verification PDF. -**Requirement coverage**: `Template-OTS-WeasyPrint`. +**Requirement coverage**: `Rendering-OTS-WeasyPrint`. ### WeasyPrint_UserGuidePdf @@ -79,10 +79,10 @@ includes expected document content. **Expected**: FileAssert exits 0 for the user guide PDF document. -**Requirement coverage**: `Template-OTS-WeasyPrint`. +**Requirement coverage**: `Rendering-OTS-WeasyPrint`. ## Requirements Coverage -- **`Template-OTS-WeasyPrint`**: WeasyPrint_BuildNotesPdf, WeasyPrint_CodeQualityPdf, +- **`Rendering-OTS-WeasyPrint`**: WeasyPrint_BuildNotesPdf, WeasyPrint_CodeQualityPdf, WeasyPrint_ReviewPlanPdf, WeasyPrint_ReviewReportPdf, WeasyPrint_DesignPdf, WeasyPrint_VerificationPdf, WeasyPrint_UserGuidePdf diff --git a/docs/verification/ots/xunit.md b/docs/verification/ots/xunit.md index a78dcbf..459c04b 100644 --- a/docs/verification/ots/xunit.md +++ b/docs/verification/ots/xunit.md @@ -12,108 +12,94 @@ correctly. ## Verification Approach -xUnit is verified by self-validation evidence from the CI pipeline. Each scenario names a specific -test method that xUnit must discover, execute, and record in a TRX result file. A passing pipeline -run for all scenarios constitutes evidence that both requirements are satisfied. +Unlike the DemaConsulting tool OTS items, which are verified by their own self-validation evidence, +xUnit is the test framework itself. It is therefore verified by this repository's own test methods: +each scenario names a real test — drawn from across the model, layout, and renderer projects — that +xUnit must discover, execute, and record in a TRX result file. A passing pipeline run for all +scenarios constitutes evidence that both requirements are satisfied. ## Test Scenarios -### Demo_DemoMethod_DefaultPrefix_ReturnsGreeting +### Get_AfterSet_ReturnsStoredValue -**Scenario**: xUnit discovers and runs this test; the test verifies that DemoMethod returns the -expected greeting using the default prefix. +**Scenario**: xUnit discovers and runs this property-system test, which stores a value and reads it +back. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `Template-OTS-xUnit-Execute`, `Template-OTS-xUnit-Report`. +**Requirement coverage**: `Rendering-OTS-xUnit-Execute`, `Rendering-OTS-xUnit-Report`. -### Demo_DemoMethod_CustomPrefix_ReturnsGreeting +### Contains_ReflectsExplicitSet -**Scenario**: xUnit discovers and runs this test; the test verifies that DemoMethod returns the -expected greeting using a custom prefix. +**Scenario**: xUnit discovers and runs this property-system test, which checks that `Contains` +reflects an explicit set. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `Template-OTS-xUnit-Execute`, `Template-OTS-xUnit-Report`. +**Requirement coverage**: `Rendering-OTS-xUnit-Execute`, `Rendering-OTS-xUnit-Report`. -### Demo_DemoMethod_NullInput_ThrowsArgumentNullException +### AddNode_AppendsNodeAndReturnsIt -**Scenario**: xUnit discovers and runs this test; the test verifies that DemoMethod rejects a null -argument with ArgumentNullException. +**Scenario**: xUnit discovers and runs this layout-graph test, which appends a node and returns it. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `Template-OTS-xUnit-Execute`, `Template-OTS-xUnit-Report`. +**Requirement coverage**: `Rendering-OTS-xUnit-Execute`, `Rendering-OTS-xUnit-Report`. -### Demo_DemoMethod_EmptyInput_ThrowsArgumentException +### LayoutTree_Construction_StoresWidthHeightNodes -**Scenario**: xUnit discovers and runs this test; the test verifies that DemoMethod rejects an -empty string argument with ArgumentException. +**Scenario**: xUnit discovers and runs this model test, which constructs a layout tree and asserts +its stored fields. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `Template-OTS-xUnit-Execute`, `Template-OTS-xUnit-Report`. +**Requirement coverage**: `Rendering-OTS-xUnit-Execute`, `Rendering-OTS-xUnit-Report`. -### Demo_Constructor_NullPrefix_ThrowsArgumentNullException +### Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges -**Scenario**: xUnit discovers and runs this test; the test verifies that the constructor rejects a -null prefix with ArgumentNullException. +**Scenario**: xUnit discovers and runs this layout test, which lays out a chain graph with the +layered algorithm. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `Template-OTS-xUnit-Execute`, `Template-OTS-xUnit-Report`. +**Requirement coverage**: `Rendering-OTS-xUnit-Execute`, `Rendering-OTS-xUnit-Report`. -### Demo_Constructor_EmptyPrefix_ThrowsArgumentException +### Id_IsLayered -**Scenario**: xUnit discovers and runs this test; the test verifies that the constructor rejects an -empty string prefix with ArgumentException. +**Scenario**: xUnit discovers and runs this layout test, which asserts the layered algorithm's stable +identifier. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `Template-OTS-xUnit-Execute`, `Template-OTS-xUnit-Report`. +**Requirement coverage**: `Rendering-OTS-xUnit-Execute`, `Rendering-OTS-xUnit-Report`. -### Demo_DefaultPrefix_Read_IsHello +### SvgRenderer_Render_SingleBox_ProducesSvgDocument -**Scenario**: xUnit discovers and runs this test; the test verifies that the DefaultPrefix constant -has the value "Hello". +**Scenario**: xUnit discovers and runs this renderer test, which renders a single box to an SVG +document. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `Template-OTS-xUnit-Execute`, `Template-OTS-xUnit-Report`. +**Requirement coverage**: `Rendering-OTS-xUnit-Execute`, `Rendering-OTS-xUnit-Report`. -### Demo_Prefix_WithCustomConstruction_ReturnsCustomPrefix +### PngRenderer_Render_SingleBox_ProducesNonEmptyOutput -**Scenario**: xUnit discovers and runs this test; the test verifies that the Prefix property -returns the value supplied at construction. +**Scenario**: xUnit discovers and runs this renderer test, which renders a single box to a non-empty +PNG. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `Template-OTS-xUnit-Execute`, `Template-OTS-xUnit-Report`. - -### Demo_DefaultConstructor_WithNoArgs_SetsDefaultPrefix - -**Scenario**: xUnit discovers and runs this test; the test verifies that the default constructor -sets Prefix to the DefaultPrefix constant. - -**Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. - -**Requirement coverage**: `Template-OTS-xUnit-Execute`, `Template-OTS-xUnit-Report`. +**Requirement coverage**: `Rendering-OTS-xUnit-Execute`, `Rendering-OTS-xUnit-Report`. ## Requirements Coverage -- **`Template-OTS-xUnit-Execute`**: Demo_DemoMethod_DefaultPrefix_ReturnsGreeting, - Demo_DemoMethod_CustomPrefix_ReturnsGreeting, - Demo_DemoMethod_NullInput_ThrowsArgumentNullException, - Demo_DemoMethod_EmptyInput_ThrowsArgumentException, - Demo_Constructor_NullPrefix_ThrowsArgumentNullException, - Demo_Constructor_EmptyPrefix_ThrowsArgumentException, Demo_DefaultPrefix_Read_IsHello, - Demo_Prefix_WithCustomConstruction_ReturnsCustomPrefix, - Demo_DefaultConstructor_WithNoArgs_SetsDefaultPrefix -- **`Template-OTS-xUnit-Report`**: Demo_DemoMethod_DefaultPrefix_ReturnsGreeting, - Demo_DemoMethod_CustomPrefix_ReturnsGreeting, - Demo_DemoMethod_NullInput_ThrowsArgumentNullException, - Demo_DemoMethod_EmptyInput_ThrowsArgumentException, - Demo_Constructor_NullPrefix_ThrowsArgumentNullException, - Demo_Constructor_EmptyPrefix_ThrowsArgumentException, Demo_DefaultPrefix_Read_IsHello, - Demo_Prefix_WithCustomConstruction_ReturnsCustomPrefix, - Demo_DefaultConstructor_WithNoArgs_SetsDefaultPrefix +- **`Rendering-OTS-xUnit-Execute`**: Get_AfterSet_ReturnsStoredValue, Contains_ReflectsExplicitSet, + AddNode_AppendsNodeAndReturnsIt, LayoutTree_Construction_StoresWidthHeightNodes, + Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges, Id_IsLayered, + SvgRenderer_Render_SingleBox_ProducesSvgDocument, + PngRenderer_Render_SingleBox_ProducesNonEmptyOutput +- **`Rendering-OTS-xUnit-Report`**: Get_AfterSet_ReturnsStoredValue, Contains_ReflectsExplicitSet, + AddNode_AppendsNodeAndReturnsIt, LayoutTree_Construction_StoresWidthHeightNodes, + Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges, Id_IsLayered, + SvgRenderer_Render_SingleBox_ProducesSvgDocument, + PngRenderer_Render_SingleBox_ProducesNonEmptyOutput diff --git a/docs/verification/rendering-abstractions/box-metrics.md b/docs/verification/rendering-abstractions/box-metrics.md new file mode 100644 index 0000000..ed6151d --- /dev/null +++ b/docs/verification/rendering-abstractions/box-metrics.md @@ -0,0 +1,36 @@ +# Box Metrics Unit Verification + +Part of the Rendering Abstractions Verification. + +This document describes the verification design for the box-metrics unit of the +`DemaConsulting.Rendering.Abstractions` system. It maps every box-metrics unit requirement to at least +one named test scenario so a reviewer can confirm coverage without reading the test code. The +verification strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Abstractions.Tests` (`BoxMetricsTests.cs`). + +## Box Metrics Unit Scenarios + +### Folder-tab height derives from theme + +Test `BoxMetrics_FolderTabHeight_DerivesFromThemeBodyFontAndPadding` calls `FolderTabHeight` with the +Light theme (body font 12, padding 6) and asserts the height is 24, equal to the body font size plus +two label paddings. + +**Covers**: `Rendering-Abstractions-BoxMetrics-FolderTabHeight`. + +### Title-area height reflects present lines + +Tests `BoxMetrics_TitleAreaHeight_NoLabelNoKeyword_IsZero`, +`BoxMetrics_TitleAreaHeight_LabelOnly_ReservesTitleLine`, and +`BoxMetrics_TitleAreaHeight_LabelAndKeyword_ReservesBothLines` assert that no title area is reserved +when neither a name nor a keyword is present, that a labelled box reserves padding plus one title line, +and that a keyword-and-name box reserves padding plus both lines. + +**Covers**: `Rendering-Abstractions-BoxMetrics-TitleAreaHeight`. + +## Requirements Coverage + +- **`Rendering-Abstractions-BoxMetrics-FolderTabHeight`**: BoxMetrics_FolderTabHeight_DerivesFromThemeBodyFontAndPadding +- **`Rendering-Abstractions-BoxMetrics-TitleAreaHeight`**: BoxMetrics_TitleAreaHeight_NoLabelNoKeyword_IsZero, + BoxMetrics_TitleAreaHeight_LabelOnly_ReservesTitleLine, BoxMetrics_TitleAreaHeight_LabelAndKeyword_ReservesBothLines diff --git a/docs/verification/rendering-abstractions/connector-label-placer.md b/docs/verification/rendering-abstractions/connector-label-placer.md new file mode 100644 index 0000000..5734785 --- /dev/null +++ b/docs/verification/rendering-abstractions/connector-label-placer.md @@ -0,0 +1,41 @@ +# Connector Label Placer Unit Verification + +Part of the Rendering Abstractions Verification. + +This document describes the verification design for the connector-label-placer unit of the +`DemaConsulting.Rendering.Abstractions` system. It maps every connector-label-placer unit requirement +to at least one named test scenario so a reviewer can confirm coverage without reading the test code. +The verification strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Abstractions.Tests` (`ConnectorLabelPlacerTests.cs`). + +## Connector Label Placer Unit Scenarios + +### Unlabelled line is omitted + +Test `Place_LineWithoutLabel_IsOmitted` places labels for a single line whose `MidpointLabel` is null +and asserts the result is empty. + +**Covers**: `Rendering-Abstractions-ConnectorLabelPlacer-OmitUnlabelled`. + +### Label uses the longest segment midpoint + +Test `Place_SingleLine_UsesLongestSegmentMidpoint` places a label for a line with a short vertical +stub followed by a long horizontal run and asserts the label lands at the midpoint of the long run +(100, 10). + +**Covers**: `Rendering-Abstractions-ConnectorLabelPlacer-LongestSegment`. + +### Colliding labels are separated + +Test `Place_CollidingLabels_AreSeparated` places labels for two lines whose longest-segment midpoints +coincide and asserts the first keeps the preferred midpoint (100, 0) while the second is nudged to a +different Y so the two do not overlap. + +**Covers**: `Rendering-Abstractions-ConnectorLabelPlacer-AvoidOverlap`. + +## Requirements Coverage + +- **`Rendering-Abstractions-ConnectorLabelPlacer-OmitUnlabelled`**: Place_LineWithoutLabel_IsOmitted +- **`Rendering-Abstractions-ConnectorLabelPlacer-LongestSegment`**: Place_SingleLine_UsesLongestSegmentMidpoint +- **`Rendering-Abstractions-ConnectorLabelPlacer-AvoidOverlap`**: Place_CollidingLabels_AreSeparated diff --git a/docs/verification/rendering-abstractions/notation-metrics.md b/docs/verification/rendering-abstractions/notation-metrics.md new file mode 100644 index 0000000..5257dfe --- /dev/null +++ b/docs/verification/rendering-abstractions/notation-metrics.md @@ -0,0 +1,75 @@ +# Notation Metrics Unit Verification + +Part of the Rendering Abstractions Verification. + +This document describes the verification design for the notation-metrics unit of the +`DemaConsulting.Rendering.Abstractions` system. It maps every notation-metrics unit requirement to at +least one named test scenario so a reviewer can confirm coverage without reading the test code. The +verification strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Abstractions.Tests` (`NotationMetricsTests.cs`). + +## Notation Metrics Unit Scenarios + +### Triangle marker geometry is canonical + +Tests `TriangleFamily_HasCanonicalValues`, `TriangleVertices_ReproduceSvgBoxPoints`, and +`TriangleVertices_Apex_OvershootsEndpoint` assert the triangle-family constants (10x7, refX 9), map +the shared vertices back to the SVG box points "0 0, 10 3.5, 0 7", and confirm the apex overshoots the +endpoint by the documented amount. + +**Covers**: `Rendering-Abstractions-NotationMetrics-TriangleGeometry`. + +### Diamond marker geometry is canonical + +Tests `Diamond_HasCanonicalValues`, `DiamondVertices_ReproduceSvgBoxPoints`, and +`DiamondVertices_FarPoint_LandsOnEndpoint` assert the diamond constants (14x8, refX 13), map the +shared vertices back to the SVG box points "1 4, 7 0, 13 4, 7 8", and confirm one vertex sits exactly +on the line endpoint. + +**Covers**: `Rendering-Abstractions-NotationMetrics-DiamondGeometry`. + +### Circle and bar geometry is canonical + +Test `CircleAndBar_HaveCanonicalValues` asserts the circle (radius 4, box 10, centre 5, refX 9) and +bar (4x12, half-along 2, half 6) constants match the historical markers. + +**Covers**: `Rendering-Abstractions-NotationMetrics-CircleBarGeometry`. + +### Crossbar is a derived fraction + +Test `Crossbar_IsDerivedFraction` asserts the crossbar fraction is 0.7 and the derived position is 7.0 +(0.7 x the marker length). + +**Covers**: `Rendering-Abstractions-NotationMetrics-Crossbar`. + +### Along-line length matches the marker box + +Test `AlongLineLength_MatchesMarkerBox` reads `AlongLineLength` for each `EndMarkerStyle` and asserts +each reports its documented length (0 for None, 10 for the triangle family and circle, 14 for +diamonds, 4 for bar). + +**Covers**: `Rendering-Abstractions-NotationMetrics-AlongLineLength`. + +### Box-decoration proportions are documented + +Tests `Port_SizeIsTwiceHalfSize`, `FolderTab_HasDocumentedConstants`, +`NoteFold_HasDocumentedConstants`, `RoundedRectRadius_IsThemeRadiusTimesFactor`, +`BadgeFractions_HaveDocumentedValues`, and `LabelBackground_ExtentMatchesInset` assert the port square, +folder-tab, note-fold, rounded-rectangle corner, badge, and label-background constants and derivations. + +**Covers**: `Rendering-Abstractions-NotationMetrics-BoxDecorations`. + +## Requirements Coverage + +- **`Rendering-Abstractions-NotationMetrics-TriangleGeometry`**: TriangleFamily_HasCanonicalValues, + TriangleVertices_ReproduceSvgBoxPoints, TriangleVertices_Apex_OvershootsEndpoint +- **`Rendering-Abstractions-NotationMetrics-DiamondGeometry`**: Diamond_HasCanonicalValues, + DiamondVertices_ReproduceSvgBoxPoints, DiamondVertices_FarPoint_LandsOnEndpoint +- **`Rendering-Abstractions-NotationMetrics-CircleBarGeometry`**: CircleAndBar_HaveCanonicalValues +- **`Rendering-Abstractions-NotationMetrics-Crossbar`**: Crossbar_IsDerivedFraction +- **`Rendering-Abstractions-NotationMetrics-AlongLineLength`**: AlongLineLength_MatchesMarkerBox +- **`Rendering-Abstractions-NotationMetrics-BoxDecorations`**: Port_SizeIsTwiceHalfSize, + FolderTab_HasDocumentedConstants, NoteFold_HasDocumentedConstants, + RoundedRectRadius_IsThemeRadiusTimesFactor, BadgeFractions_HaveDocumentedValues, + LabelBackground_ExtentMatchesInset diff --git a/docs/verification/rendering-abstractions/registries.md b/docs/verification/rendering-abstractions/registries.md new file mode 100644 index 0000000..087528f --- /dev/null +++ b/docs/verification/rendering-abstractions/registries.md @@ -0,0 +1,50 @@ +# Registries Unit Verification + +Part of the Rendering Abstractions Verification. + +This document describes the verification design for the registries unit of the +`DemaConsulting.Rendering.Abstractions` system. It maps every registries unit requirement to at least +one named test scenario so a reviewer can confirm coverage without reading the test code. The +verification strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Abstractions.Tests` (`RegistryTests.cs`). + +## Registries Unit Scenarios + +### Algorithm registers and resolves by id + +Test `LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm` registers an algorithm, then +asserts that `Contains("fake")` is true and `Resolve("fake")` returns the registered algorithm. + +**Covers**: `Rendering-Abstractions-Registries-ResolveAlgorithm`. + +### Renderer registers and resolves by media type + +Test `RendererRegistry_RegisterThenResolve_ReturnsRenderer` registers a renderer, then asserts that +`Contains("text/plain")` is true and `Resolve("text/plain")` returns the registered renderer. + +**Covers**: `Rendering-Abstractions-Registries-ResolveRenderer`. + +### Renderer resolves by file extension + +Test `RendererRegistry_ResolveByExtension_MatchesAdvertisedExtensions` registers a renderer that +advertises `.txt` and `.text`, asserts `ContainsExtension(".txt")`, resolves `.txt`, and resolves +`TEXT` without a leading dot. The assertions prove that every advertised extension is indexed and that +extension lookup ignores case and tolerates an omitted leading dot. + +**Covers**: `Rendering-Abstractions-Registries-ResolveRendererByExtension`. + +### Resolving a missing id throws + +Test `LayoutAlgorithmRegistry_ResolveMissing_Throws` resolves an identifier that was never registered +and asserts that `Resolve` throws `KeyNotFoundException`. + +**Covers**: `Rendering-Abstractions-Registries-MissingThrows`. + +## Requirements Coverage + +- **`Rendering-Abstractions-Registries-ResolveAlgorithm`**: LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm +- **`Rendering-Abstractions-Registries-ResolveRenderer`**: RendererRegistry_RegisterThenResolve_ReturnsRenderer +- **`Rendering-Abstractions-Registries-ResolveRendererByExtension`**: + RendererRegistry_ResolveByExtension_MatchesAdvertisedExtensions +- **`Rendering-Abstractions-Registries-MissingThrows`**: LayoutAlgorithmRegistry_ResolveMissing_Throws diff --git a/docs/verification/rendering-abstractions/rendering-abstractions.md b/docs/verification/rendering-abstractions/rendering-abstractions.md new file mode 100644 index 0000000..ea44c4e --- /dev/null +++ b/docs/verification/rendering-abstractions/rendering-abstractions.md @@ -0,0 +1,59 @@ +# Rendering Abstractions Verification + +This document describes the system-level verification design for the +`DemaConsulting.Rendering.Abstractions` system and links to the per-unit verification documents for +its six units. It records the verification strategy, test environment, and acceptance criteria shared +by every unit, and maps each system-level requirement to named test scenarios. The detailed +per-requirement scenarios live in the unit documents: + +- Rendering Contracts Unit Verification +- Registries Unit Verification +- Theme Unit Verification +- Notation Metrics Unit Verification +- Box Metrics Unit Verification +- Connector Label Placer Unit Verification + +## Verification Strategy + +The abstractions system is verified through in-process unit tests. The contract interfaces +(`ILayoutAlgorithm`, `IRenderer`) are exercised through minimal fake implementations that the registry +tests register and resolve, confirming the identity members of each contract. The registry tests also +verify extension-based renderer lookup using advertised file-extension aliases. The registries, theme, +and the three geometry helpers are pure and deterministic, so their tests construct inputs directly +and assert on returned values. No external services or filesystem access are involved, and the only +test doubles are the in-test `FakeAlgorithm` and `FakeRenderer` used to exercise registry lookup. + +## Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK. +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline. +- **Dependencies**: none; no external services, network, or filesystem access. +- **Isolation**: each test constructs its own registry, theme reference, or geometry inputs. +- **Test project**: `DemaConsulting.Rendering.Abstractions.Tests` (`RegistryTests.cs`, + `ThemeTests.cs`, `NotationMetricsTests.cs`, `BoxMetricsTests.cs`, `ConnectorLabelPlacerTests.cs`). + +## Acceptance Criteria + +A verification run passes when every scenario in this system document and in the six unit documents +passes without error or unexpected exception. Any wrong returned value, wrong geometry, or unexpected +exception constitutes a failure. + +## System Requirements Coverage + +The system requirements are satisfied through the unit scenarios documented in the per-unit +verification files; the representative system-level scenarios are: + +- **`Rendering-Abstractions-Extensibility`**: + LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm, + RendererRegistry_RegisterThenResolve_ReturnsRenderer, + RendererRegistry_ResolveByExtension_MatchesAdvertisedExtensions (see + Rendering Contracts Unit Verification and + Registries Unit Verification) +- **`Rendering-Abstractions-Theming`**: ConnectorApproachZone_SumsStubBendAndClearance, + Themes_HaveExpectedConnectorGeometry (see Theme Unit Verification) +- **`Rendering-Abstractions-SharedGeometry`**: TriangleFamily_HasCanonicalValues, + BoxMetrics_FolderTabHeight_DerivesFromThemeBodyFontAndPadding, + Place_SingleLine_UsesLongestSegmentMidpoint (see + Notation Metrics Unit Verification, + Box Metrics Unit Verification, and + Connector Label Placer Unit Verification) diff --git a/docs/verification/rendering-abstractions/rendering-contracts.md b/docs/verification/rendering-abstractions/rendering-contracts.md new file mode 100644 index 0000000..9de4a8d --- /dev/null +++ b/docs/verification/rendering-abstractions/rendering-contracts.md @@ -0,0 +1,44 @@ +# Rendering Contracts Unit Verification + +Part of the Rendering Abstractions Verification. + +This document describes the verification design for the rendering-contracts unit of the +`DemaConsulting.Rendering.Abstractions` system. It maps every rendering-contracts unit requirement to +at least one named test scenario so a reviewer can confirm coverage without reading the test code. The +verification strategy, test environment, and acceptance criteria are described in the +system verification document; the primary test project is +`DemaConsulting.Rendering.Abstractions.Tests` (`RegistryTests.cs`). Concrete renderer extension +coverage also appears in `DemaConsulting.Rendering.Skia.Tests` (`SkiaFormatRendererTests.cs`). + +## Rendering Contracts Unit Scenarios + +The contract interfaces carry no behavior of their own; their identity members are verified through +the fake implementations registered in the registry tests. `FakeAlgorithm` implements +`ILayoutAlgorithm` (returning `Id` "fake") and `FakeRenderer` implements `IRenderer` (returning +`MediaType` "text/plain", `DefaultExtension` ".txt", and `FileExtensions` ".txt" and ".text"). + +### Algorithm contract identity is exercised + +Test `LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm` registers a `FakeAlgorithm`, +resolves it, and reads the resolved algorithm's `Id`, asserting it is "fake" and thereby exercising +`ILayoutAlgorithm.Id`. + +**Covers**: `Rendering-Abstractions-Contracts-Algorithm`. + +### Renderer contract identity is exercised + +Test `RendererRegistry_RegisterThenResolve_ReturnsRenderer` registers a `FakeRenderer`, resolves it, +and reads the resolved renderer's `MediaType`, asserting it is "text/plain" and thereby exercising +`IRenderer.MediaType`. + +Test `PngRenderer_FileExtensions_ContainsDefault` constructs the concrete PNG renderer and asserts +that its advertised `FileExtensions` contains its `DefaultExtension`, proving concrete renderers honor +the extension-advertising part of the `IRenderer` contract. + +**Covers**: `Rendering-Abstractions-Contracts-Renderer`. + +## Requirements Coverage + +- **`Rendering-Abstractions-Contracts-Algorithm`**: LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm +- **`Rendering-Abstractions-Contracts-Renderer`**: RendererRegistry_RegisterThenResolve_ReturnsRenderer, + PngRenderer_FileExtensions_ContainsDefault diff --git a/docs/verification/rendering-abstractions/theme.md b/docs/verification/rendering-abstractions/theme.md new file mode 100644 index 0000000..ca86e5f --- /dev/null +++ b/docs/verification/rendering-abstractions/theme.md @@ -0,0 +1,32 @@ +# Theme Unit Verification + +Part of the Rendering Abstractions Verification. + +This document describes the verification design for the theme unit of the +`DemaConsulting.Rendering.Abstractions` system. It maps every theme unit requirement to at least one +named test scenario so a reviewer can confirm coverage without reading the test code. The verification +strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Abstractions.Tests` (`ThemeTests.cs`). + +## Theme Unit Scenarios + +### Approach zone sums stub, bend, and clearance + +Test `ConnectorApproachZone_SumsStubBendAndClearance` calls `ConnectorApproachZone(10.0)` on the Light +theme (stub 8, bend radius 4) and asserts the result is 22.0. + +**Covers**: `Rendering-Abstractions-Theme-ApproachZone`. + +### Built-in themes carry expected geometry + +Test `Themes_HaveExpectedConnectorGeometry` reads the connector stub and bend radius of the Light, +Dark, and Print themes and asserts Light and Dark carry stub 8 and bend radius 4 while Print carries +stub 6 and bend radius 0. + +**Covers**: `Rendering-Abstractions-Theme-BuiltInGeometry`. + +## Requirements Coverage + +- **`Rendering-Abstractions-Theme-ApproachZone`**: ConnectorApproachZone_SumsStubBendAndClearance +- **`Rendering-Abstractions-Theme-BuiltInGeometry`**: Themes_HaveExpectedConnectorGeometry diff --git a/docs/verification/rendering-layout/connector-router.md b/docs/verification/rendering-layout/connector-router.md new file mode 100644 index 0000000..45a3eff --- /dev/null +++ b/docs/verification/rendering-layout/connector-router.md @@ -0,0 +1,41 @@ +# ConnectorRouter Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the connector-router unit requirements to named test scenarios. + +## ConnectorRouter Unit Scenarios + +- **Anchors face each other** (`Rendering-Layout-ConnectorRouter-AnchorsFaceEachOther`): + `Route_TargetToTheRight_AnchorsFaceEachOther` and `Route_TargetBelow_AnchorsFaceEachOther` confirm + the route starts and ends on the box faces that point at the opposing box. +- **Obstacle avoidance** (`Rendering-Layout-ConnectorRouter-AvoidsObstacles`): + `Route_ObstacleBetweenEndpoints_RoutesAroundInterior` confirms the route is orthogonal and never + enters an intervening box's interior. +- **Endpoint exclusion** (`Rendering-Layout-ConnectorRouter-ExcludesEndpoints`): + `Route_EndpointBoxes_AreExcludedFromObstacles` confirms the connector reaches the endpoints' boundary + anchors even though both boxes appear in the box list. +- **Styling carried** (`Rendering-Layout-ConnectorRouter-CarriesStyling`): + `Route_Connection_CarriesRequestedStyling` confirms the requested target marker, line style, and + label flow onto the produced line while the source end stays unmarked. +- **Batch order** (`Rendering-Layout-ConnectorRouter-BatchOrder`): + `Route_MultipleConnections_ReturnsOneLinePerConnectionInOrder` confirms one line per connection in + input order. +- **Validation** (`Rendering-Layout-ConnectorRouter-Validation`): `Route_NullBoxes_Throws`, + `Route_NullConnections_Throws`, `Route_NullOptions_Throws`, and `Route_NullConnection_Throws` confirm + null arguments are rejected with an argument-null error. + +## Requirements Coverage + +- **`Rendering-Layout-ConnectorRouter-AnchorsFaceEachOther`**: + Route_TargetToTheRight_AnchorsFaceEachOther, Route_TargetBelow_AnchorsFaceEachOther +- **`Rendering-Layout-ConnectorRouter-AvoidsObstacles`**: + Route_ObstacleBetweenEndpoints_RoutesAroundInterior +- **`Rendering-Layout-ConnectorRouter-ExcludesEndpoints`**: + Route_EndpointBoxes_AreExcludedFromObstacles +- **`Rendering-Layout-ConnectorRouter-CarriesStyling`**: + Route_Connection_CarriesRequestedStyling +- **`Rendering-Layout-ConnectorRouter-BatchOrder`**: + Route_MultipleConnections_ReturnsOneLinePerConnectionInOrder +- **`Rendering-Layout-ConnectorRouter-Validation`**: + Route_NullBoxes_Throws, Route_NullConnections_Throws, Route_NullOptions_Throws, Route_NullConnection_Throws diff --git a/docs/verification/rendering-layout/containment-layout-algorithm.md b/docs/verification/rendering-layout/containment-layout-algorithm.md new file mode 100644 index 0000000..d2be112 --- /dev/null +++ b/docs/verification/rendering-layout/containment-layout-algorithm.md @@ -0,0 +1,44 @@ +# ContainmentLayoutAlgorithm Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the containment-layout-algorithm unit requirements to named test scenarios. + +## ContainmentLayoutAlgorithm Scenarios + +- **Identity** (`Rendering-Layout-ContainmentAlgorithm-Identity`): `Id_IsContainment` asserts the + algorithm reports the stable `"containment"` identifier. +- **Packs nodes** (`Rendering-Layout-ContainmentAlgorithm-PacksNodes`): + `Apply_Graph_PacksNodesNonOverlappingInInputOrderWithinCanvas` confirms one placed box per node, in + input order, with no two boxes overlapping and every box inside the reported canvas. +- **Routes edges** (`Rendering-Layout-ContainmentAlgorithm-RoutesEdges`): + `Apply_Graph_RoutesOneConnectorPerEdgeCarryingStyling` confirms one routed connector per input edge, + each carrying the edge's target end marker, line style, and label. +- **Routes around obstacle** (`Rendering-Layout-ContainmentAlgorithm-RoutesAroundObstacle`): + `Apply_EdgeCrossingInterveningBox_RoutesAroundIt` confirms an edge whose endpoints straddle an + intervening packed box is routed around that box's interior. +- **Empty graph** (`Rendering-Layout-ContainmentAlgorithm-EmptyGraph`): + `Apply_EmptyGraph_ReturnsEmptyCanvas` confirms an empty graph yields an empty placed tree with a + positive-size canvas. +- **Skips out-of-graph edges** (`Rendering-Layout-ContainmentAlgorithm-SkipsOutOfGraphEdges`): + `Apply_EdgeReferencingOutOfGraphNode_IsSkipped` confirms an edge whose endpoint is not a top-level + node is skipped rather than routed. +- **Validation** (`Rendering-Layout-ContainmentAlgorithm-Validation`): `Apply_NullGraph_Throws` and + `Apply_NullOptions_Throws` confirm null arguments are rejected with an argument-null error. + +## Requirements Coverage + +- **`Rendering-Layout-ContainmentAlgorithm-Identity`**: + Id_IsContainment +- **`Rendering-Layout-ContainmentAlgorithm-PacksNodes`**: + Apply_Graph_PacksNodesNonOverlappingInInputOrderWithinCanvas +- **`Rendering-Layout-ContainmentAlgorithm-RoutesEdges`**: + Apply_Graph_RoutesOneConnectorPerEdgeCarryingStyling +- **`Rendering-Layout-ContainmentAlgorithm-RoutesAroundObstacle`**: + Apply_EdgeCrossingInterveningBox_RoutesAroundIt +- **`Rendering-Layout-ContainmentAlgorithm-EmptyGraph`**: + Apply_EmptyGraph_ReturnsEmptyCanvas +- **`Rendering-Layout-ContainmentAlgorithm-SkipsOutOfGraphEdges`**: + Apply_EdgeReferencingOutOfGraphNode_IsSkipped +- **`Rendering-Layout-ContainmentAlgorithm-Validation`**: + Apply_NullGraph_Throws, Apply_NullOptions_Throws diff --git a/docs/verification/rendering-layout/containment-layout.md b/docs/verification/rendering-layout/containment-layout.md new file mode 100644 index 0000000..1b53466 --- /dev/null +++ b/docs/verification/rendering-layout/containment-layout.md @@ -0,0 +1,54 @@ +# ContainmentLayout Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the containment-layout unit requirements to named test scenarios. + +## ContainmentLayout Scenarios + +- **Order preserved** (`Rendering-Layout-ContainmentLayout-Order`): + `Pack_ItemsFitInRow_PreservesOrderLeftToRight` confirms the packed children keep their input order, + positioned left to right along a shared row. +- **No overlap** (`Rendering-Layout-ContainmentLayout-NoOverlap`): `Pack_MixedSizes_ProducesNoOverlaps` + asserts no two packed children overlap for a multi-row mix of sizes. +- **Within region** (`Rendering-Layout-ContainmentLayout-WithinRegion`): + `Pack_MixedSizes_AllChildrenWithinRegion` asserts every child lies inside the reported region. +- **Wrapping** (`Rendering-Layout-ContainmentLayout-Wrapping`): + `Pack_ChildExceedsWidth_WrapsToNewRow` confirms an overflowing child starts a new row beneath the + current one at the left origin. +- **Oversized child** (`Rendering-Layout-ContainmentLayout-OversizedChild`): + `Pack_OversizedChild_PlacedAloneAndRegionWidens` confirms a child wider than the content width is + placed alone and the region widens to contain it. +- **Empty input** (`Rendering-Layout-ContainmentLayout-EmptyInput`): + `Pack_EmptyInput_ReturnsPaddingOnlyRegion` confirms an empty input yields no children and a + padding-only region. +- **Fields preserved** (`Rendering-Layout-ContainmentLayout-PreservesFields`): + `Pack_PreservesNonPositionFields` confirms label, depth, shape, compartments, nested children, and + keyword survive unchanged while only X and Y are updated. +- **Option defaults** (`Rendering-Layout-ContainmentLayout-Defaults`): + `ContainmentOptions_Defaults_AreSensibleGapsAndPadding` confirms the default gaps are eight pixels and + the default padding is twelve pixels. +- **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. + +## Requirements Coverage + +- **`Rendering-Layout-ContainmentLayout-Order`**: + Pack_ItemsFitInRow_PreservesOrderLeftToRight +- **`Rendering-Layout-ContainmentLayout-NoOverlap`**: + Pack_MixedSizes_ProducesNoOverlaps +- **`Rendering-Layout-ContainmentLayout-WithinRegion`**: + Pack_MixedSizes_AllChildrenWithinRegion +- **`Rendering-Layout-ContainmentLayout-Wrapping`**: + Pack_ChildExceedsWidth_WrapsToNewRow +- **`Rendering-Layout-ContainmentLayout-OversizedChild`**: + Pack_OversizedChild_PlacedAloneAndRegionWidens +- **`Rendering-Layout-ContainmentLayout-EmptyInput`**: + Pack_EmptyInput_ReturnsPaddingOnlyRegion +- **`Rendering-Layout-ContainmentLayout-PreservesFields`**: + Pack_PreservesNonPositionFields +- **`Rendering-Layout-ContainmentLayout-Defaults`**: + ContainmentOptions_Defaults_AreSensibleGapsAndPadding +- **`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 new file mode 100644 index 0000000..bb7d56d --- /dev/null +++ b/docs/verification/rendering-layout/default-layout.md @@ -0,0 +1,58 @@ +# DefaultLayout Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the default-layout unit requirements to named test scenarios. + +## DefaultLayout Scenarios + +- **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. +- **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 + laid out through the hierarchical engine to a result identical to the layered leaf algorithm. +- **Resolution** (`Rendering-Layout-LayoutEngine-Resolution`): + `Layout_OptionsDeclareLayered_MatchesLayeredAlgorithmExactly` and + `Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly` confirm an explicit options + declaration selects that algorithm; `Layout_GraphDeclarationOverridesOptions` confirms a graph-level + declaration takes precedence over the options. +- **Flat equivalence** (`Rendering-Layout-LayoutEngine-FlatEquivalence`): + `Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly` and + `Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly` deep-compare the facade's output + bit-for-bit with the leaf algorithm applied directly, proving the facade changes no existing output. +- **Nested composition** (`Rendering-Layout-LayoutEngine-NestedComposition`): + `Layout_NestedGraphNoAlgorithmDeclared_ProducesComposedTree` confirms an undeclared nested graph is + composed so the container box carries its recursively laid-out children. +- **Custom registry** (`Rendering-Layout-LayoutEngine-CustomRegistry`): + `Layout_CustomRegistry_ResolvesRegisteredAlgorithm` confirms a caller-supplied registry's algorithm is + resolved and applied; `Layout_UnregisteredAlgorithm_Throws` confirms an unknown identifier surfaces a + key-not-found error. +- **Validation** (`Rendering-Layout-LayoutEngine-Validation`): `Layout_NullGraph_Throws`, + `Layout_NullOptions_Throws`, and `Layout_NullRegistry_Throws` confirm null arguments are rejected with + an argument-null error. + +## Requirements Coverage + +- **`Rendering-Layout-DefaultRegistry-BundledAlgorithms`**: + CreateDefaultRegistry_ResolvesLayeredAlgorithm, CreateDefaultRegistry_ResolvesContainmentAlgorithm, + CreateDefaultRegistry_ResolvesHierarchicalAlgorithm, CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms, + CreateDefaultRegistry_ReturnsIndependentInstances +- **`Rendering-Layout-LayoutEngine-DefaultAlgorithm`**: + DefaultAlgorithmId_IsHierarchical, Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly +- **`Rendering-Layout-LayoutEngine-Resolution`**: + Layout_OptionsDeclareLayered_MatchesLayeredAlgorithmExactly, + Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly, Layout_GraphDeclarationOverridesOptions +- **`Rendering-Layout-LayoutEngine-FlatEquivalence`**: + Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly, + Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly +- **`Rendering-Layout-LayoutEngine-NestedComposition`**: + Layout_NestedGraphNoAlgorithmDeclared_ProducesComposedTree +- **`Rendering-Layout-LayoutEngine-CustomRegistry`**: + Layout_CustomRegistry_ResolvesRegisteredAlgorithm, Layout_UnregisteredAlgorithm_Throws +- **`Rendering-Layout-LayoutEngine-Validation`**: + Layout_NullGraph_Throws, Layout_NullOptions_Throws, Layout_NullRegistry_Throws diff --git a/docs/verification/rendering-layout/edge-routing-option.md b/docs/verification/rendering-layout/edge-routing-option.md new file mode 100644 index 0000000..fe7518a --- /dev/null +++ b/docs/verification/rendering-layout/edge-routing-option.md @@ -0,0 +1,25 @@ +# EdgeRouting Option Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the edge-routing-option behavior requirements to named test scenarios. + +## EdgeRouting Option Unit Scenarios + +- **Per-scope selection** (`Rendering-Layout-EdgeRouting-Selection`): + `CoreOptions_EdgeRouting_DefaultsToOrthogonal` and `CoreOptions_EdgeRouting_HasStableId` confirm the + `rendering.edgerouting` key defaults to `Orthogonal` and carries the ELK-flavored id; + `CoreOptions_EdgeRouting_SelectablePerScope` sets and reads the style back through the property + system, and `CoreOptions_EdgeRouting_UnsetReturnsDefault` confirms an unset scope falls back to the + orthogonal default. +- **Route-option defaults** (`Rendering-Layout-EdgeRouting-Defaults`): + `ConnectorRouteOptions_Defaults_AreOrthogonalWithTwelvePixelClearance` confirms the default style is + orthogonal, the default clearance is twelve logical pixels, and the clearance is caller-overridable. + +## Requirements Coverage + +- **`Rendering-Layout-EdgeRouting-Selection`**: + CoreOptions_EdgeRouting_DefaultsToOrthogonal, CoreOptions_EdgeRouting_HasStableId, + CoreOptions_EdgeRouting_SelectablePerScope, CoreOptions_EdgeRouting_UnsetReturnsDefault +- **`Rendering-Layout-EdgeRouting-Defaults`**: + ConnectorRouteOptions_Defaults_AreOrthogonalWithTwelvePixelClearance diff --git a/docs/verification/rendering-layout/engine/containment-packer.md b/docs/verification/rendering-layout/engine/containment-packer.md new file mode 100644 index 0000000..b5acea1 --- /dev/null +++ b/docs/verification/rendering-layout/engine/containment-packer.md @@ -0,0 +1,42 @@ +# ContainmentPacker Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the ContainmentPacker unit requirements to named test scenarios. + +## ContainmentPacker Unit Scenarios + +- **Single row** (`Rendering-Layout-ContainmentPacker-SingleRow`): `Pack_ItemsFitInRow_ShareSameRow` + asserts items that fit the width budget share one row, left to right. +- **Wrapping** (`Rendering-Layout-ContainmentPacker-Wrapping`): + `Pack_ItemsExceedWidth_WrapToNewRow` confirms an overflowing item starts a new row beneath the + current one. +- **No overlap** (`Rendering-Layout-ContainmentPacker-NoOverlap`): + `Pack_MixedSizes_ProducesNoOverlaps` asserts no two packed rectangles overlap for a mix of sizes. +- **Within bounds** (`Rendering-Layout-ContainmentPacker-WithinBounds`): + `Pack_MixedSizes_AllRectsWithinBounds` asserts every rectangle lies inside the reported region. +- **Oversized item** (`Rendering-Layout-ContainmentPacker-OversizedItem`): + `Pack_ItemWiderThanContentWidth_PlacedAloneAndRegionWidens` confirms an oversized item is placed + alone and the region widens to contain it. +- **Empty input** (`Rendering-Layout-ContainmentPacker-EmptyInput`): + `Pack_EmptyList_ReturnsPaddingOnlyRegion` confirms an empty input yields a padding-only region. +- **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. + +## Requirements Coverage + +- **`Rendering-Layout-ContainmentPacker-SingleRow`**: + Pack_ItemsFitInRow_ShareSameRow +- **`Rendering-Layout-ContainmentPacker-Wrapping`**: + Pack_ItemsExceedWidth_WrapToNewRow +- **`Rendering-Layout-ContainmentPacker-NoOverlap`**: + Pack_MixedSizes_ProducesNoOverlaps +- **`Rendering-Layout-ContainmentPacker-WithinBounds`**: + Pack_MixedSizes_AllRectsWithinBounds +- **`Rendering-Layout-ContainmentPacker-OversizedItem`**: + Pack_ItemWiderThanContentWidth_PlacedAloneAndRegionWidens +- **`Rendering-Layout-ContainmentPacker-EmptyInput`**: + Pack_EmptyList_ReturnsPaddingOnlyRegion +- **`Rendering-Layout-ContainmentPacker-SingleItem`**: + Pack_SingleItem_PositionsAtPaddingOrigin diff --git a/docs/verification/rendering-layout/engine/engine.md b/docs/verification/rendering-layout/engine/engine.md new file mode 100644 index 0000000..c8b26c2 --- /dev/null +++ b/docs/verification/rendering-layout/engine/engine.md @@ -0,0 +1,24 @@ +# Engine Subsystem Verification + +Part of the Rendering Layout Verification. + +This document describes subsystem-level verification coverage for the Rendering.Layout Engine subsystem. +Unit scenarios live in the Engine unit verification documents: + +- OrthogonalEdgeRouter Unit Verification +- ContainmentPacker Unit Verification +- InterconnectionLayoutEngine Unit Verification +- Layered Pipeline Unit Verification + +## Engine Subsystem Coverage + +- **`Rendering-Layout-OrthogonalRouting`**: + Route_NoObstacles_ProducesOrthogonalPath, + Route_ObstacleBetween_RoutesAround. Detailed by the OrthogonalEdgeRouter unit verification. +- **`Rendering-Layout-Containment`**: + Pack_MixedSizes_ProducesNoOverlaps, + Pack_ItemsFitInRow_ShareSameRow. Detailed by the ContainmentPacker unit verification. +- **`Rendering-Layout-Interconnection`**: + Place_LinearChain_MonotonicLayerAssignment, + Place_WorkstationTopology_CorrectLayersAndNoOverlap. Detailed by the InterconnectionLayoutEngine and + Layered Pipeline unit verification documents. diff --git a/docs/verification/rendering-layout/engine/interconnection-layout-engine.md b/docs/verification/rendering-layout/engine/interconnection-layout-engine.md new file mode 100644 index 0000000..638a73d --- /dev/null +++ b/docs/verification/rendering-layout/engine/interconnection-layout-engine.md @@ -0,0 +1,38 @@ +# InterconnectionLayoutEngine Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the InterconnectionLayoutEngine unit requirements to named test scenarios. + +## InterconnectionLayoutEngine Unit Scenarios + +- **Layering** (`Rendering-Layout-InterconnectionEngine-Layering`): + `Place_LinearChain_MonotonicLayerAssignment` asserts monotonic layer indices along a chain, and + `Place_WorkstationTopology_CorrectLayersAndNoOverlap` verifies correct layers on a representative + topology. +- **Non-overlapping** (`Rendering-Layout-InterconnectionEngine-NonOverlapping`): + `Place_WorkstationTopology_CorrectLayersAndNoOverlap` asserts placed rectangles do not overlap. +- **Dummy nodes** (`Rendering-Layout-InterconnectionEngine-DummyNodes`): + `Place_LongEdge_RectCountEqualsInputNodeCount` confirms dummy nodes are excluded from the returned + rectangles, and `Place_LongEdge_RoutesViaDummyNodesWithinBounds` confirms long edges route through + them within bounds. +- **Waypoints** (`Rendering-Layout-InterconnectionEngine-Waypoints`): + `Place_SingleEdge_ProducesStraightTwoWaypointPath` confirms a span-one edge produces a straight + two-waypoint path; `Place_LongEdge_RoutesViaDummyNodesWithinBounds` confirms a long edge's route. +- **Deterministic** (`Rendering-Layout-InterconnectionEngine-Deterministic`): the layering scenarios + `Place_LinearChain_MonotonicLayerAssignment` and + `Place_WorkstationTopology_CorrectLayersAndNoOverlap` assert fixed, reproducible geometry for fixed + input. + +## Requirements Coverage + +- **`Rendering-Layout-InterconnectionEngine-Layering`**: + Place_LinearChain_MonotonicLayerAssignment, Place_WorkstationTopology_CorrectLayersAndNoOverlap +- **`Rendering-Layout-InterconnectionEngine-NonOverlapping`**: + Place_WorkstationTopology_CorrectLayersAndNoOverlap +- **`Rendering-Layout-InterconnectionEngine-DummyNodes`**: + Place_LongEdge_RectCountEqualsInputNodeCount, Place_LongEdge_RoutesViaDummyNodesWithinBounds +- **`Rendering-Layout-InterconnectionEngine-Waypoints`**: + Place_SingleEdge_ProducesStraightTwoWaypointPath, Place_LongEdge_RoutesViaDummyNodesWithinBounds +- **`Rendering-Layout-InterconnectionEngine-Deterministic`**: + Place_LinearChain_MonotonicLayerAssignment, Place_WorkstationTopology_CorrectLayersAndNoOverlap diff --git a/docs/verification/rendering-layout/engine/layered-pipeline.md b/docs/verification/rendering-layout/engine/layered-pipeline.md new file mode 100644 index 0000000..b9a6313 --- /dev/null +++ b/docs/verification/rendering-layout/engine/layered-pipeline.md @@ -0,0 +1,139 @@ +# Layered Pipeline Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the layered-pipeline unit requirements to named test scenarios. + +The pipeline stages are exercised individually, and the assembled pipeline is checked for byte-exact +equivalence with the legacy oracle. Dependencies are real (each stage operates on a `LayeredGraph`); +nothing is mocked. + +- **Staged pipeline** (`Rendering-Layout-LayeredPipeline-StagedPipeline`): + `LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing` runs the full + default sequence and confirms waypoints are populated; `Pipeline_MatchesLegacyOracle_OnRandomGraphs` + confirms the assembled sequence matches the oracle. +- **Behavior preserving** (`Rendering-Layout-LayeredPipeline-BehaviorPreserving`): the byte-for-byte + legacy-oracle equivalence suite — `Pipeline_MatchesLegacyOracle_OnRandomGraphs`, + `Pipeline_MatchesLegacyOracle_OnEmptyGraph`, `Pipeline_MatchesLegacyOracle_OnDroneLikeGraph`, + `Pipeline_MatchesLegacyOracle_OnWorkstationLikeGraph`, and + `Pipeline_MatchesLegacyOracle_OnNamedTopologies`. +- **Flat hierarchy only** (`Rendering-Layout-LayeredPipeline-FlatHierarchyOnly`): + `LayeredLayoutPipeline_Build_RecursiveHierarchy_ThrowsNotSupportedException` confirms recursive + handling fails fast. +- **Directions** (`Rendering-Layout-LayeredPipeline-Directions`): + `AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged`, + `AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces`, + `AxisTransform_Apply_Down_PlacesTargetSouthWithCorrectFaces`, + `AxisTransform_Apply_Left_PlacesTargetWestWithCorrectFaces`, and + `AxisTransform_Apply_Up_PlacesTargetNorthWithCorrectFaces` cover all four flow directions. +- **Orthogonal connectors** (`Rendering-Layout-LayeredPipeline-OrthogonalConnectors`): + `AxisTransform_Apply_Right_ProducesOrthogonalWaypoints`, + `AxisTransform_Apply_Down_ProducesOrthogonalWaypoints`, + `AxisTransform_Apply_Left_ProducesOrthogonalWaypoints`, and + `AxisTransform_Apply_Up_ProducesOrthogonalWaypoints` assert axis-aligned waypoints per direction. +- **Cycle breaking** (`Rendering-Layout-LayeredPipeline-CycleBreaking`): + `CycleBreaker_Apply_GraphWithCycle_ProducesAcyclicEdgeSet` and + `CycleBreaker_Apply_SelfLoopsAndDuplicates_AreRemoved`. +- **Layer assignment** (`Rendering-Layout-LayeredPipeline-LayerAssignment`): + `LayerAssigner_Apply_LinearChain_AssignsMonotonicLayers` and + `LayerAssigner_Apply_DiamondGraph_AssignsLongestPathLayers`. +- **Long-edge splitting** (`Rendering-Layout-LayeredPipeline-LongEdgeSplitting`): + `LongEdgeSplitter_Apply_SpanOneEdge_AddsNoDummyNodes` and + `LongEdgeSplitter_Apply_LongEdge_InsertsDummyNodesPerIntermediateLayer`. +- **Crossing minimization** (`Rendering-Layout-LayeredPipeline-CrossingMinimization`): + `CrossingMinimizer_Apply_CrossingProneOrdering_ReducesCrossings`, + `CrossingMinimizer_Apply_TwoLayerGraph_GroupsNodesByLayer`, and + `CrossingMinimizer_Apply_AllAugmentedNodesAppearInGroups`. +- **Coordinate assignment** (`Rendering-Layout-LayeredPipeline-CoordinateAssignment`): + `BrandesKopfPlacer_Apply_ChainGraph_AssignsCoordinateArrays`, + `BrandesKopfPlacer_Apply_ColumnsAreLeftToRightInLayerOrder`, and + `BrandesKopfPlacer_Apply_SymmetricFork_CentersSourceBetweenTargets`. +- **Port distribution** (`Rendering-Layout-LayeredPipeline-PortDistribution`): + `PortDistributor_Apply_SingleEdge_PortsLieWithinNodeFaces` and + `PortDistributor_Apply_AssignsPortYForEverySubEdge`. +- **Orthogonal routing** (`Rendering-Layout-LayeredPipeline-OrthogonalRouting`): + `OrthogonalRouter_Apply_StraightEdge_ProducesNoBendPoints` and + `OrthogonalRouter_Apply_EveryBendListIsEmptyOrVerticalSegment`. +- **Back-edge approach** (`Rendering-Layout-LayeredPipeline-BackEdgeApproach`): + `OrthogonalRouter_DefaultApproach_IsByteIdenticalToLegacy` (byte-identity), + `OrthogonalRouter_ForwardEdges_GeometryUnchanged`, `OrthogonalRouter_AcyclicGraph_NoApproachChange`, + `OrthogonalRouter_CustomApproach_PushesEntryStubOutward`, + `OrthogonalRouter_ReversedEdge_DefaultApproachClearsClearance`, and + `OrthogonalRouter_DecorationAwareApproach_ClearsMarkerAlongLength`. +- **Long-edge joining** (`Rendering-Layout-LayeredPipeline-LongEdgeJoining`): + `LongEdgeJoiner_Apply_SingleEdge_ProducesWaypointsPerOriginalEdge` and + `LongEdgeJoiner_Apply_LongEdge_ConcatenatesSubEdgeBendPoints`. +- **Component packing** (`Rendering-Layout-LayeredPipeline-ComponentPacking`): + `ComponentPacker_Apply_DisconnectedSingletons_PackSeparately`, + `ComponentPacker_Apply_ConnectedCore_StaysOneComponent`, + `ComponentPacker_Apply_SingleComponent_EqualsDefaultPipeline`, + `ComponentPacker_Apply_ComponentOrder_IsDeterministic`, + `ComponentPacker_Apply_Waypoints_TranslatedWithComponent`, `ComponentPacker_Apply_EmptyGraph_IsNoOp`, + `ComponentPacker_Apply_SingleComponent_ParallelAndSelfEdges_ProducesAlignedWaypoints`, and + `ComponentPacker_Apply_MultiComponent_ParallelAndSelfEdges_MergesAlignedWaypoints`. +- **Shared state** (`Rendering-Layout-LayeredPipeline-SharedState`): + `LayeredGraph_Constructor_ValidInput_StoresNodesEdgesDirectionAndCount`, + `LayeredGraph_Constructor_NullNodes_ThrowsArgumentNullException`, and + `LayeredGraph_Constructor_NullEdges_ThrowsArgumentNullException`. +- **Input validation** (`Rendering-Layout-LayeredPipeline-InputValidation`): + `LayeredLayoutPipeline_AddStage_NullStage_ThrowsArgumentNullException` and + `LayeredLayoutPipeline_Run_NullGraph_ThrowsArgumentNullException`. + +## Requirements Coverage + +- **`Rendering-Layout-LayeredPipeline-StagedPipeline`**: + LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing, + Pipeline_MatchesLegacyOracle_OnRandomGraphs +- **`Rendering-Layout-LayeredPipeline-BehaviorPreserving`**: + Pipeline_MatchesLegacyOracle_OnRandomGraphs, Pipeline_MatchesLegacyOracle_OnEmptyGraph, + Pipeline_MatchesLegacyOracle_OnDroneLikeGraph, Pipeline_MatchesLegacyOracle_OnWorkstationLikeGraph, + Pipeline_MatchesLegacyOracle_OnNamedTopologies +- **`Rendering-Layout-LayeredPipeline-FlatHierarchyOnly`**: + LayeredLayoutPipeline_Build_RecursiveHierarchy_ThrowsNotSupportedException +- **`Rendering-Layout-LayeredPipeline-Directions`**: + AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged, + AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces, + AxisTransform_Apply_Down_PlacesTargetSouthWithCorrectFaces, + AxisTransform_Apply_Left_PlacesTargetWestWithCorrectFaces, AxisTransform_Apply_Up_PlacesTargetNorthWithCorrectFaces +- **`Rendering-Layout-LayeredPipeline-OrthogonalConnectors`**: + AxisTransform_Apply_Right_ProducesOrthogonalWaypoints, AxisTransform_Apply_Down_ProducesOrthogonalWaypoints, + AxisTransform_Apply_Left_ProducesOrthogonalWaypoints, AxisTransform_Apply_Up_ProducesOrthogonalWaypoints +- **`Rendering-Layout-LayeredPipeline-CycleBreaking`**: + CycleBreaker_Apply_GraphWithCycle_ProducesAcyclicEdgeSet, CycleBreaker_Apply_SelfLoopsAndDuplicates_AreRemoved +- **`Rendering-Layout-LayeredPipeline-LayerAssignment`**: + LayerAssigner_Apply_LinearChain_AssignsMonotonicLayers, LayerAssigner_Apply_DiamondGraph_AssignsLongestPathLayers +- **`Rendering-Layout-LayeredPipeline-LongEdgeSplitting`**: + LongEdgeSplitter_Apply_SpanOneEdge_AddsNoDummyNodes, + LongEdgeSplitter_Apply_LongEdge_InsertsDummyNodesPerIntermediateLayer +- **`Rendering-Layout-LayeredPipeline-CrossingMinimization`**: + CrossingMinimizer_Apply_CrossingProneOrdering_ReducesCrossings, + CrossingMinimizer_Apply_TwoLayerGraph_GroupsNodesByLayer, CrossingMinimizer_Apply_AllAugmentedNodesAppearInGroups +- **`Rendering-Layout-LayeredPipeline-CoordinateAssignment`**: + BrandesKopfPlacer_Apply_ChainGraph_AssignsCoordinateArrays, + BrandesKopfPlacer_Apply_ColumnsAreLeftToRightInLayerOrder, + BrandesKopfPlacer_Apply_SymmetricFork_CentersSourceBetweenTargets +- **`Rendering-Layout-LayeredPipeline-PortDistribution`**: + PortDistributor_Apply_SingleEdge_PortsLieWithinNodeFaces, PortDistributor_Apply_AssignsPortYForEverySubEdge +- **`Rendering-Layout-LayeredPipeline-OrthogonalRouting`**: + OrthogonalRouter_Apply_StraightEdge_ProducesNoBendPoints, + OrthogonalRouter_Apply_EveryBendListIsEmptyOrVerticalSegment +- **`Rendering-Layout-LayeredPipeline-BackEdgeApproach`**: + OrthogonalRouter_DefaultApproach_IsByteIdenticalToLegacy, OrthogonalRouter_CustomApproach_PushesEntryStubOutward, + OrthogonalRouter_ReversedEdge_DefaultApproachClearsClearance, OrthogonalRouter_ForwardEdges_GeometryUnchanged, + OrthogonalRouter_AcyclicGraph_NoApproachChange, OrthogonalRouter_DecorationAwareApproach_ClearsMarkerAlongLength +- **`Rendering-Layout-LayeredPipeline-LongEdgeJoining`**: + LongEdgeJoiner_Apply_SingleEdge_ProducesWaypointsPerOriginalEdge, + LongEdgeJoiner_Apply_LongEdge_ConcatenatesSubEdgeBendPoints +- **`Rendering-Layout-LayeredPipeline-ComponentPacking`**: + ComponentPacker_Apply_DisconnectedSingletons_PackSeparately, ComponentPacker_Apply_ConnectedCore_StaysOneComponent, + ComponentPacker_Apply_SingleComponent_EqualsDefaultPipeline, ComponentPacker_Apply_ComponentOrder_IsDeterministic, + ComponentPacker_Apply_Waypoints_TranslatedWithComponent, ComponentPacker_Apply_EmptyGraph_IsNoOp, + ComponentPacker_Apply_SingleComponent_ParallelAndSelfEdges_ProducesAlignedWaypoints, + ComponentPacker_Apply_MultiComponent_ParallelAndSelfEdges_MergesAlignedWaypoints +- **`Rendering-Layout-LayeredPipeline-SharedState`**: + LayeredGraph_Constructor_ValidInput_StoresNodesEdgesDirectionAndCount, + LayeredGraph_Constructor_NullNodes_ThrowsArgumentNullException, + LayeredGraph_Constructor_NullEdges_ThrowsArgumentNullException +- **`Rendering-Layout-LayeredPipeline-InputValidation`**: + LayeredLayoutPipeline_AddStage_NullStage_ThrowsArgumentNullException, + LayeredLayoutPipeline_Run_NullGraph_ThrowsArgumentNullException diff --git a/docs/verification/rendering-layout/engine/orthogonal-edge-router.md b/docs/verification/rendering-layout/engine/orthogonal-edge-router.md new file mode 100644 index 0000000..01d3890 --- /dev/null +++ b/docs/verification/rendering-layout/engine/orthogonal-edge-router.md @@ -0,0 +1,45 @@ +# OrthogonalEdgeRouter Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the OrthogonalEdgeRouter unit requirements to named test scenarios. + +## OrthogonalEdgeRouter Unit Scenarios + +- **Orthogonal path** (`Rendering-Layout-OrthogonalEdgeRouter-Orthogonal`): + `Route_NoObstacles_ProducesOrthogonalPath` asserts consecutive waypoints share an X or Y + coordinate; `Route_AlignedEndpoints_ProducesStraightLine` confirms aligned anchors yield a + straight two-point path. +- **Obstacle avoidance** (`Rendering-Layout-OrthogonalEdgeRouter-AvoidObstacles`): + `Route_ObstacleBetween_RoutesAround` and `Route_MultipleObstacles_RemainsValid` verify the path + never enters an obstacle interior; `RouteWithStatus_ObstacleBetween_RoutesAroundWithoutCrossing` + confirms the crossing flag stays clear when a clean detour exists. +- **Clearance** (`Rendering-Layout-OrthogonalEdgeRouter-Clearance`): + `RouteWithStatus_CleanRoute_KeepsClearanceFromObstacles` asserts routed segments keep the + requested clearance from obstacles. +- **Perpendicular ends** (`Rendering-Layout-OrthogonalEdgeRouter-PerpendicularEnds`): + `Route_WithSourceSide_LeavesPerpendicular` and `Route_WithTargetSide_EntersPerpendicular` confirm + the connector leaves/enters an anchor perpendicular to the given box side. +- **Crossing status** (`Rendering-Layout-OrthogonalEdgeRouter-CrossingStatus`): + `RouteWithStatus_NoBlockingObstacle_ReportsNotCrossed` reports a clean route, and + `RouteWithStatus_TargetEnclosedByObstacle_ReportsCrossed` reports a forced crossing for an enclosed + target. +- **Cost bands** (`Rendering-Layout-OrthogonalEdgeRouter-CostBands`): + `RouteWithStatus_HighwayBand_PrefersBandedDetour` confirms the router prefers a discounted band + over an equal-length alternative. + +## Requirements Coverage + +- **`Rendering-Layout-OrthogonalEdgeRouter-Orthogonal`**: + Route_NoObstacles_ProducesOrthogonalPath, Route_AlignedEndpoints_ProducesStraightLine +- **`Rendering-Layout-OrthogonalEdgeRouter-AvoidObstacles`**: + Route_ObstacleBetween_RoutesAround, Route_MultipleObstacles_RemainsValid, + RouteWithStatus_ObstacleBetween_RoutesAroundWithoutCrossing +- **`Rendering-Layout-OrthogonalEdgeRouter-Clearance`**: + RouteWithStatus_CleanRoute_KeepsClearanceFromObstacles +- **`Rendering-Layout-OrthogonalEdgeRouter-PerpendicularEnds`**: + Route_WithSourceSide_LeavesPerpendicular, Route_WithTargetSide_EntersPerpendicular +- **`Rendering-Layout-OrthogonalEdgeRouter-CrossingStatus`**: + RouteWithStatus_NoBlockingObstacle_ReportsNotCrossed, RouteWithStatus_TargetEnclosedByObstacle_ReportsCrossed +- **`Rendering-Layout-OrthogonalEdgeRouter-CostBands`**: + RouteWithStatus_HighwayBand_PrefersBandedDetour diff --git a/docs/verification/rendering-layout/hierarchical-layout-algorithm.md b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md new file mode 100644 index 0000000..1f713a6 --- /dev/null +++ b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md @@ -0,0 +1,60 @@ +# HierarchicalLayoutAlgorithm Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the hierarchical-layout-algorithm unit requirements to named test scenarios. + +## HierarchicalLayoutAlgorithm Scenarios + +- **Identity** (`Rendering-Layout-HierarchicalLayout-Identity`): `Id_IsHierarchical` asserts the engine + reports the stable `"hierarchical"` identifier. +- **Flat equivalence** (`Rendering-Layout-HierarchicalLayout-FlatEquivalence`): + `Apply_FlatRandomGraphs_MatchLayeredAlgorithmExactly` and + `Apply_FlatRandomGraphs_MatchContainmentAlgorithmExactly` feed hundreds of pseudo-random flat graphs + through the engine and the selected leaf algorithm and deep-compare the two placed trees bit-for-bit + (canvas size, node kinds, box geometry and attributes, and every line waypoint), proving a graph with + no container nodes is placed identically to the leaf algorithm applied directly. +- **Nests children** (`Rendering-Layout-HierarchicalLayout-NestsChildren`): + `Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely` confirms a container is sized to + enclose its children and each nested box lies within the container's bounds; + `Apply_ThreeLevelNesting_Succeeds` confirms three levels compose so a box contains a box that contains + a box. +- **Per-node algorithm** (`Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm`): + `Apply_ContainmentRootWithLayeredContainer_Composes` and + `Apply_LayeredRootWithContainmentContainer_Composes` confirm a container overriding its algorithm is + laid out with that algorithm while its parent uses another, composing with nested children. +- **Hierarchy handling** (`Rendering-Layout-HierarchicalLayout-HierarchyHandling`): + `Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely` confirms children are laid out in + their own coordinate space and composed into the parent at absolute coordinates. + `Apply_CompoundGraph_DoesNotMutateInputNodeSizes` confirms the engine sizes containers over an + internal sized view and never mutates the caller's input node dimensions. +- **Cross-container edge** (`Rendering-Layout-HierarchicalLayout-CrossContainerEdge`): + `Apply_CrossContainerEdge_RoutesAroundInterveningContainer` confirms an edge between children of + different sibling containers is routed at the owning scope and no routed segment passes through the + intervening container's interior. +- **Validation** (`Rendering-Layout-HierarchicalLayout-ValidatesGraph`, + `Rendering-Layout-HierarchicalLayout-ValidatesOptions`, + `Rendering-Layout-HierarchicalLayout-ValidatesRegistry`): `Apply_NullGraph_Throws`, + `Apply_NullOptions_Throws`, and `Constructor_NullRegistry_Throws` confirm a null graph, null options, + and null registry are each rejected with an argument-null error. + +## Requirements Coverage + +- **`Rendering-Layout-HierarchicalLayout-Identity`**: + Id_IsHierarchical +- **`Rendering-Layout-HierarchicalLayout-FlatEquivalence`**: + Apply_FlatRandomGraphs_MatchLayeredAlgorithmExactly, Apply_FlatRandomGraphs_MatchContainmentAlgorithmExactly +- **`Rendering-Layout-HierarchicalLayout-NestsChildren`**: + Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely, Apply_ThreeLevelNesting_Succeeds +- **`Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm`**: + Apply_ContainmentRootWithLayeredContainer_Composes, Apply_LayeredRootWithContainmentContainer_Composes +- **`Rendering-Layout-HierarchicalLayout-HierarchyHandling`**: + Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely, Apply_CompoundGraph_DoesNotMutateInputNodeSizes +- **`Rendering-Layout-HierarchicalLayout-CrossContainerEdge`**: + Apply_CrossContainerEdge_RoutesAroundInterveningContainer +- **`Rendering-Layout-HierarchicalLayout-ValidatesGraph`**: + Apply_NullGraph_Throws +- **`Rendering-Layout-HierarchicalLayout-ValidatesOptions`**: + Apply_NullOptions_Throws +- **`Rendering-Layout-HierarchicalLayout-ValidatesRegistry`**: + Constructor_NullRegistry_Throws diff --git a/docs/verification/rendering-layout/layered-layout-algorithm.md b/docs/verification/rendering-layout/layered-layout-algorithm.md new file mode 100644 index 0000000..86d94eb --- /dev/null +++ b/docs/verification/rendering-layout/layered-layout-algorithm.md @@ -0,0 +1,27 @@ +# LayeredLayoutAlgorithm Unit Verification + +Part of the Rendering Layout Verification. + +This document maps the layered-layout-algorithm unit requirements to named test scenarios. + +- **Identity** (`Rendering-Layout-LayeredAlgorithm-Identity`): `Id_IsLayered` asserts the algorithm + reports the stable `"layered"` identifier. +- **Places and routes** (`Rendering-Layout-LayeredAlgorithm-PlacesAndRoutes`): + `Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges` confirms one placed box per node and one routed + connector per edge for a chain graph. +- **Empty graph** (`Rendering-Layout-LayeredAlgorithm-EmptyGraph`): + `Apply_EmptyGraph_ReturnsEmptyCanvas` confirms an empty graph yields an empty placed layout tree. +- **Validation** (`Rendering-Layout-LayeredAlgorithm-Validation`): `Apply_NullGraph_Throws` confirms + a null graph argument is rejected with an argument-null error, and `Apply_NullOptions_Throws` + confirms a null options argument is likewise rejected with an argument-null error. + +## Requirements Coverage + +- **`Rendering-Layout-LayeredAlgorithm-Identity`**: + Id_IsLayered +- **`Rendering-Layout-LayeredAlgorithm-PlacesAndRoutes`**: + Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges +- **`Rendering-Layout-LayeredAlgorithm-EmptyGraph`**: + Apply_EmptyGraph_ReturnsEmptyCanvas +- **`Rendering-Layout-LayeredAlgorithm-Validation`**: + Apply_NullGraph_Throws, Apply_NullOptions_Throws diff --git a/docs/verification/rendering-layout/rendering-layout.md b/docs/verification/rendering-layout/rendering-layout.md new file mode 100644 index 0000000..ec34788 --- /dev/null +++ b/docs/verification/rendering-layout/rendering-layout.md @@ -0,0 +1,69 @@ +# Rendering.Layout Verification + +This document describes the system-level verification design for `DemaConsulting.Rendering.Layout` and +links to the subsystem and unit verification documents. Detailed per-requirement scenarios live in the +linked documents below; this file maps only the Rendering.Layout system requirements. + +- Engine Subsystem Verification +- EdgeRoutingOption Unit Verification +- ConnectorRouter Unit Verification +- ContainmentLayout Unit Verification +- ContainmentLayoutAlgorithm Unit Verification +- HierarchicalLayoutAlgorithm Unit Verification +- DefaultLayout Unit Verification +- LayeredLayoutAlgorithm Unit Verification + +## Verification Strategy + +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. + +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. + +## Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK. +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline. +- **Location**: `test/DemaConsulting.Rendering.Layout.Tests/`. +- **Dependencies**: no external services, files, or network access; tests use in-memory graphs. +- **Isolation**: each test builds its own inputs; engines and algorithms are stateless between calls. + +## Acceptance Criteria + +A verification run passes when every representative system scenario below and every scenario in the +linked subsystem and unit verification documents passes without unexpected exception. Any wrong rectangle, +waypoint, layer assignment, algorithm identity, registry resolution, containment region, or unsupported +input behavior constitutes a failure. + +## System Requirements Coverage + +The system requirements are satisfied through subsystem and unit scenarios documented in the linked +verification files; representative system-level coverage is: + +- **`Rendering-Layout-Algorithm`**: `Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges`, `Id_IsLayered` + (see LayeredLayoutAlgorithm Unit Verification). +- **`Rendering-Layout-GeometricEngines`**: `Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges` (see + Engine Subsystem Verification). +- **`Rendering-Layout-StagedPipeline`**: + `LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing`, + `Pipeline_MatchesLegacyOracle_OnRandomGraphs` (see + LayeredPipeline Unit Verification). +- **`Rendering-Layout-ConnectorRouting`**: `Route_TargetToTheRight_AnchorsFaceEachOther`, + `Route_ObstacleBetweenEndpoints_RoutesAroundInterior` (see + EdgeRoutingOption Unit Verification and + ConnectorRouter Unit Verification). +- **`Rendering-Layout-ContainmentPlacement`**: `Pack_ItemsFitInRow_PreservesOrderLeftToRight`, + `Pack_MixedSizes_ProducesNoOverlaps` (see + ContainmentLayout Unit Verification). +- **`Rendering-Layout-ContainmentAlgorithm`**: + `Apply_Graph_PacksNodesNonOverlappingInInputOrderWithinCanvas`, `Id_IsContainment` (see + ContainmentLayoutAlgorithm Unit Verification). +- **`Rendering-Layout-HierarchicalLayout`**: `Id_IsHierarchical`, + `Apply_FlatRandomGraphs_MatchLayeredAlgorithmExactly` (see + HierarchicalLayoutAlgorithm Unit Verification). +- **`Rendering-Layout-DefaultLayout`**: `CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms`, + `Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly` (see + DefaultLayout Unit Verification). diff --git a/docs/verification/rendering-skia/jpeg-renderer.md b/docs/verification/rendering-skia/jpeg-renderer.md new file mode 100644 index 0000000..a2d3d0e --- /dev/null +++ b/docs/verification/rendering-skia/jpeg-renderer.md @@ -0,0 +1,24 @@ +# JpegRenderer Unit Verification + +Part of the Rendering.Skia Verification. + +This document describes the verification design for the `JpegRenderer` unit of the +`DemaConsulting.Rendering.Skia` system. It maps every JpegRenderer unit requirement to at least one +named test scenario so a reviewer can confirm coverage without reading the test code. The verification +strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Skia.Tests` (`SkiaFormatRendererTests.cs`). + +## JpegRenderer Unit Scenarios + +### JPEG output has the expected signature and metadata + +Test `JpegRenderer_Render_ProducesJpegSignature` renders a sample layout and asserts that output begins +with the JPEG Start-Of-Image marker and that the renderer reports the `image/jpeg` media type, the +`.jpg` default extension, and the `.jpeg` alternate extension. + +**Covers**: `Rendering-Skia-JpegRenderer-EmitsJpeg`. + +## Requirements Coverage + +- **`Rendering-Skia-JpegRenderer-EmitsJpeg`**: JpegRenderer_Render_ProducesJpegSignature diff --git a/docs/verification/rendering-skia/png-renderer.md b/docs/verification/rendering-skia/png-renderer.md new file mode 100644 index 0000000..98d63e8 --- /dev/null +++ b/docs/verification/rendering-skia/png-renderer.md @@ -0,0 +1,27 @@ +# PngRenderer Unit Verification + +Part of the Rendering.Skia Verification. + +This document describes the verification design for the `PngRenderer` unit of the +`DemaConsulting.Rendering.Skia` system. It maps every PngRenderer unit requirement to at least one +named test scenario so a reviewer can confirm coverage without reading the test code. The verification +strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Skia.Tests` (`PngRendererTests.cs`, `PngRendererPortedTests.cs`, +`SkiaFormatRendererTests.cs`). + +## PngRenderer Unit Scenarios + +### PNG output has the expected signature and metadata + +Tests `Render_SingleBox_ProducesPngSignature`, `PngRenderer_Render_EmptyTree_WritesPngSignature`, and +`PngRenderer_FileExtensions_ContainsDefault` render sample layouts and assert that output begins with +the PNG signature and that the renderer reports the `image/png` media type and a `.png` extension that +is included in its advertised extensions. + +**Covers**: `Rendering-Skia-PngRenderer-EmitsPng`. + +## Requirements Coverage + +- **`Rendering-Skia-PngRenderer-EmitsPng`**: Render_SingleBox_ProducesPngSignature, + PngRenderer_Render_EmptyTree_WritesPngSignature, PngRenderer_FileExtensions_ContainsDefault diff --git a/docs/verification/rendering-skia/rendering-skia.md b/docs/verification/rendering-skia/rendering-skia.md new file mode 100644 index 0000000..c9d9cd2 --- /dev/null +++ b/docs/verification/rendering-skia/rendering-skia.md @@ -0,0 +1,44 @@ +# Rendering.Skia Verification + +This document describes the system-level verification design for the `DemaConsulting.Rendering.Skia` +system and links to the per-unit verification documents for its four units. It records the verification +strategy, test environment, and acceptance criteria shared by every unit, and maps the system-level +requirement to representative named test scenarios. The detailed per-requirement scenarios live in the +unit documents: + +- SkiaRasterRenderer Unit Verification +- PngRenderer Unit Verification +- JpegRenderer Unit Verification +- WebpRenderer Unit Verification + +## Verification Strategy + +The Skia renderers are verified by unit tests that render small layout trees and assert on the produced +bytes. Format is checked by the encoded file signature (PNG signature, JPEG Start-Of-Image marker, WEBP +RIFF container header). Drawing behaviour is checked by inspecting individual pixels of the decoded PNG +bitmap, including fill colours, stroke colours, and the theme background. The PNG pixel tests exercise +the shared `SkiaRasterRenderer` base that all three formats share, so they also establish the drawing +correctness of the JPEG and WEBP renderers. + +## Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK (net8.0, net9.0, net10.0). +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline. +- **Mocking**: none required; renderers are pure and deterministic. +- **Isolation**: each test renders into its own `MemoryStream`. +- **Test project**: `DemaConsulting.Rendering.Skia.Tests` (`PngRendererTests.cs`, + `PngRendererPortedTests.cs`, `PngEndMarkerTests.cs`, `SkiaFormatRendererTests.cs`). + +## Acceptance Criteria + +A verification run passes when every scenario in this system document and in the four unit documents +passes without error or unexpected exception. Any wrong encoded signature, media type, file extension, +pixel colour, marker geometry, or unexpected exception constitutes a failure. + +## System Requirements Coverage + +The system requirement is satisfied through the unit scenarios documented in the per-unit verification +files; the representative system-level scenarios are: + +- **`Rendering-Skia-RenderRasterImage`**: Render_SingleBox_ProducesPngSignature, + JpegRenderer_Render_ProducesJpegSignature, WebpRenderer_Render_ProducesWebpContainerHeader diff --git a/docs/verification/rendering-skia/skia-raster-renderer.md b/docs/verification/rendering-skia/skia-raster-renderer.md new file mode 100644 index 0000000..82162c0 --- /dev/null +++ b/docs/verification/rendering-skia/skia-raster-renderer.md @@ -0,0 +1,78 @@ +# SkiaRasterRenderer Unit Verification + +Part of the Rendering.Skia Verification. + +This document describes the verification design for the `SkiaRasterRenderer` unit of the +`DemaConsulting.Rendering.Skia` system. It maps every SkiaRasterRenderer unit requirement to at least +one named test scenario so a reviewer can confirm coverage without reading the test code. The +verification strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Skia.Tests` (`PngRendererPortedTests.cs`, `PngEndMarkerTests.cs`). + +## SkiaRasterRenderer Unit Scenarios + +### Draws all layout-tree node kinds + +Tests `PngRenderer_Render_SingleBox_ProducesNonEmptyOutput`, `PngRenderer_Render_BackgroundIsThemeBackground`, +`PngRenderer_Render_SingleLine_PixelOnLineIsStrokeColor`, +`PngRenderer_Render_SinglePort_CenterPixelIsStrokeColor`, +`PngRenderer_Render_SingleBadge_FilledCircle_CenterPixelIsStrokeColor`, +`PngRenderer_Render_SingleLifeline_StemPixelIsStrokeColor`, +`PngRenderer_Render_SingleActivation_CenterPixelIsWhite`, +`PngRenderer_Render_SingleBand_BorderIsStrokeColor`, +`PngRenderer_Render_DeeplyNestedBoxes_DoesNotStackOverflow`, and +`PngRenderer_Render_LabelWithXmlSpecialCharacters_ProducesValidPng` render layout trees containing the +supported node kinds and assert that representative pixels take the expected colour or that rendering +produces a valid PNG without overflowing the stack. + +The background contract is that the bitmap is initialized from `RenderOptions.Theme.BackgroundColor`. +The `PngRenderer_Render_BackgroundIsThemeBackground` theory renders an empty tree with both the light +and dark themes and asserts the top-left pixel equals each theme's background color; the dark theme, +whose background is not white, proves the fill is genuinely theme-driven rather than a hardcoded white. + +**Covers**: `Rendering-Skia-SkiaRasterRenderer-DrawsLayoutTree`. + +### Theme colours drive fills + +Tests `PngRenderer_Render_SingleBox_FillColorMatchesTheme`, +`PngRenderer_Render_SingleBox_DepthOneUsesSecondColor`, and +`PngRenderer_Render_SingleGrid_HeaderFillMatchesTheme` render boxes and grids and assert that fill +pixels equal the theme depth-palette colour selected by nesting depth. + +**Covers**: `Rendering-Skia-SkiaRasterRenderer-ThemeColours`. + +### End markers match notation metrics + +Tests `FilledArrow_AlongLength_MatchesNotationMetrics`, `FilledArrow_BaseWidth_MatchesNotationMetrics`, +`OpenChevron_HasFewerInkPixelsThanClosedTriangle`, and +`PngRenderer_Render_DrawArrowhead_OpenWithCrossbar_ProducesNonEmptyOutput` assert that rendered +end-marker geometry derives from the shared notation metrics and that distinct marker styles produce +distinguishable output. + +**Covers**: `Rendering-Skia-SkiaRasterRenderer-EndMarkers`. + +### Empty tree renders as a valid image + +Test `PngRenderer_Render_EmptyTree_WritesPngSignature` renders an empty layout tree and asserts that a +valid image with the PNG signature is produced, proving the minimum one by one pixel bitmap path. + +**Covers**: `Rendering-Skia-SkiaRasterRenderer-EmptyTree`. + +## Requirements Coverage + +- **`Rendering-Skia-SkiaRasterRenderer-DrawsLayoutTree`**: + PngRenderer_Render_SingleBox_ProducesNonEmptyOutput, PngRenderer_Render_BackgroundIsThemeBackground, + PngRenderer_Render_SingleLine_PixelOnLineIsStrokeColor, + PngRenderer_Render_SinglePort_CenterPixelIsStrokeColor, + PngRenderer_Render_SingleBadge_FilledCircle_CenterPixelIsStrokeColor, + PngRenderer_Render_SingleLifeline_StemPixelIsStrokeColor, + PngRenderer_Render_SingleActivation_CenterPixelIsWhite, + PngRenderer_Render_SingleBand_BorderIsStrokeColor, + PngRenderer_Render_DeeplyNestedBoxes_DoesNotStackOverflow, + PngRenderer_Render_LabelWithXmlSpecialCharacters_ProducesValidPng +- **`Rendering-Skia-SkiaRasterRenderer-ThemeColours`**: PngRenderer_Render_SingleBox_FillColorMatchesTheme, + PngRenderer_Render_SingleBox_DepthOneUsesSecondColor, PngRenderer_Render_SingleGrid_HeaderFillMatchesTheme +- **`Rendering-Skia-SkiaRasterRenderer-EndMarkers`**: FilledArrow_AlongLength_MatchesNotationMetrics, + FilledArrow_BaseWidth_MatchesNotationMetrics, OpenChevron_HasFewerInkPixelsThanClosedTriangle, + PngRenderer_Render_DrawArrowhead_OpenWithCrossbar_ProducesNonEmptyOutput +- **`Rendering-Skia-SkiaRasterRenderer-EmptyTree`**: PngRenderer_Render_EmptyTree_WritesPngSignature diff --git a/docs/verification/rendering-skia/webp-renderer.md b/docs/verification/rendering-skia/webp-renderer.md new file mode 100644 index 0000000..d7f2909 --- /dev/null +++ b/docs/verification/rendering-skia/webp-renderer.md @@ -0,0 +1,24 @@ +# WebpRenderer Unit Verification + +Part of the Rendering.Skia Verification. + +This document describes the verification design for the `WebpRenderer` unit of the +`DemaConsulting.Rendering.Skia` system. It maps every WebpRenderer unit requirement to at least one +named test scenario so a reviewer can confirm coverage without reading the test code. The verification +strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Skia.Tests` (`SkiaFormatRendererTests.cs`). + +## WebpRenderer Unit Scenarios + +### WEBP output has the expected container header and metadata + +Test `WebpRenderer_Render_ProducesWebpContainerHeader` renders a sample layout and asserts that output +is a RIFF/WEBP container and that the renderer reports the `image/webp` media type and `.webp` file +extension. + +**Covers**: `Rendering-Skia-WebpRenderer-EmitsWebp`. + +## Requirements Coverage + +- **`Rendering-Skia-WebpRenderer-EmitsWebp`**: WebpRenderer_Render_ProducesWebpContainerHeader diff --git a/docs/verification/rendering-svg/rendering-svg.md b/docs/verification/rendering-svg/rendering-svg.md new file mode 100644 index 0000000..eaf5cd0 --- /dev/null +++ b/docs/verification/rendering-svg/rendering-svg.md @@ -0,0 +1,39 @@ +# Rendering.Svg Verification + +This document describes the system-level verification design for the `DemaConsulting.Rendering.Svg` +system and links to the per-unit verification document for its single unit. Detailed per-requirement +scenarios live in SvgRenderer Unit Verification. + +## Verification Strategy + +The SVG system is verified through in-process xUnit tests that render placed `LayoutTree` inputs and +assert on the emitted SVG markup. The system-level smoke scenario exercises the public `IRenderer.Render` +entry point, decodes the written UTF-8 bytes, and confirms that a representative placed tree produces a +valid SVG document. Detailed element, styling, escaping, and marker-geometry scenarios are covered by the +SvgRenderer unit verification. + +No mocking is required because the renderer is pure and stateless. Tests construct concrete model inputs, +use `Themes.Light`, write to `MemoryStream`, and inspect the resulting SVG text. + +## Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK. +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline. +- **Dependencies**: no external services, databases, files, or network access. +- **Isolation**: each test constructs its own renderer, layout tree, render options, and stream. +- **Test project**: `DemaConsulting.Rendering.Svg.Tests`. + +## Acceptance Criteria + +A verification run passes when the system scenario below and every scenario in +SvgRenderer Unit Verification pass without unexpected exception. Any missing SVG root, +wrong renderer metadata, wrong emitted element or attribute, malformed XML escaping, or marker geometry +that does not match `NotationMetrics` constitutes a failure. + +## System Requirements Coverage + +The system requirement is satisfied through the SvgRenderer unit scenarios; the representative +system-level scenario is: + +- **`Rendering-Svg-WriteSvgDocument`**: `Render_SingleBox_ProducesSvgDocument` (see + SvgRenderer Unit Verification). diff --git a/docs/verification/rendering-svg/svg-renderer.md b/docs/verification/rendering-svg/svg-renderer.md new file mode 100644 index 0000000..106e367 --- /dev/null +++ b/docs/verification/rendering-svg/svg-renderer.md @@ -0,0 +1,175 @@ +# SvgRenderer Unit Verification + +Part of the Rendering.Svg Verification. + +This document describes the verification design for the `SvgRenderer` unit of the +`DemaConsulting.Rendering.Svg` system. It maps every SvgRenderer unit requirement to at least one +named test scenario so a reviewer can confirm coverage without reading the test code. The verification +strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is +`DemaConsulting.Rendering.Svg.Tests`. + +## SvgRenderer Unit Scenarios + +### Renderer contract and metadata + +Test `SvgRenderer_Render_SingleBox_ProducesSvgDocument` renders a single box, asserts that SVG markup +is produced, and checks `MediaType` is `image/svg+xml` and `DefaultExtension` is `.svg`. + +**Covers**: `Rendering-Svg-SvgRenderer-ImplementsIRenderer`, +`Rendering-Svg-SvgRenderer-MediaType`, `Rendering-Svg-SvgRenderer-DefaultExtension`. + +### SVG document root and empty tree + +Test `SvgRenderer_Render_EmptyTree_ProducesSvgDocument` renders an empty `LayoutTree`, asserts the +output stream is non-empty, and checks that the decoded text contains ``. + +**Covers**: `Rendering-Svg-SvgRenderer-RenderDocument`, +`Rendering-Svg-SvgRenderer-RenderEmptyTree`. + +### Box rectangle, rounded corners, and compartments + +Tests `SvgRenderer_Render_SingleBox_ProducesRectElement`, +`SvgRenderer_Render_BoxRoundedRectangle_ProducesRxAttribute`, and +`SvgRenderer_Render_BoxWithCompartment_ProducesLineAndText` render boxes and assert that a `` +element, an `rx` rounded-corner attribute, and compartment divider/text content are present. + +**Covers**: `Rendering-Svg-SvgRenderer-RenderBox`, +`Rendering-Svg-SvgRenderer-RenderBoxRoundedCorners`, +`Rendering-Svg-SvgRenderer-RenderBoxCompartments`. + +### Label text, styling, and escaping + +Tests `SvgRenderer_Render_SingleLabel_ProducesTextElement`, +`SvgRenderer_Render_LabelWithBold_ProducesBoldAttribute`, +`SvgRenderer_Render_LabelWithItalic_ProducesItalicAttribute`, and +`SvgRenderer_Render_LabelWithXmlSpecialCharacters_ProducesWellFormedEscapedSvg` render labels and +assert on ``, bold and italic attributes, escaped XML entities, and that the output remains +well-formed XML. + +**Covers**: `Rendering-Svg-SvgRenderer-RenderLabel`, +`Rendering-Svg-SvgRenderer-RenderLabelBold`, `Rendering-Svg-SvgRenderer-RenderLabelItalic`, +`Rendering-Svg-SvgRenderer-RenderLabelEscaping`. + +### Connector path, corners, dash pattern, and label + +Tests `SvgRenderer_Render_SingleLine_ProducesPathElement`, +`SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath`, +`SvgRenderer_Render_SingleLine_Dashed_ProducesDashArray`, and +`SvgRenderer_Render_LineWithMidpointLabel_ProducesTextElement` render connector lines and assert on +``, an arc command, `stroke-dasharray`, and the connector label text. + +The midpoint-label scenario asserts that a `` element containing the label appears in the final +SVG. The design places connector labels in a final pass using `ConnectorLabelPlacer`; this scenario +does not assert the exact placed coordinates or collision-avoidance behavior. + +**Covers**: `Rendering-Svg-SvgRenderer-RenderLine`, +`Rendering-Svg-SvgRenderer-RenderLineRoundedCorners`, +`Rendering-Svg-SvgRenderer-RenderLineDashed`, +`Rendering-Svg-SvgRenderer-RenderLineMidpointLabel`. + +### Additional node kinds + +Tests `SvgRenderer_Render_SinglePort_ProducesRect`, +`SvgRenderer_Render_SingleBadge_FilledCircle_ProducesCircle`, +`SvgRenderer_Render_SingleBand_ProducesRect`, +`SvgRenderer_Render_SingleLifeline_ProducesRectAndLine`, +`SvgRenderer_Render_SingleActivation_ProducesRect`, and +`SvgRenderer_Render_SingleGrid_ProducesRects` assert that ports, filled-circle badges, bands, +lifelines, activations, and grids emit their expected SVG element types. + +**Covers**: `Rendering-Svg-SvgRenderer-RenderNodeKinds`, +`Rendering-Svg-SvgRenderer-RenderBadge`, `Rendering-Svg-SvgRenderer-RenderBand`, +`Rendering-Svg-SvgRenderer-RenderLifeline`, `Rendering-Svg-SvgRenderer-RenderActivation`, +`Rendering-Svg-SvgRenderer-RenderGrid`. + +### Connector end markers + +Tests `OpenChevron_IsDefinedAsPolyline` and `OpenChevronLine_ReferencesOpenChevronMarker` assert that +the open-chevron marker definition contains `` and no ``, and that an open-chevron +line contains `marker-end="url(#line-end-open-chevron)"`. + +Tests `HollowTriangle_IsDefinedAsClosedPolygon`, +`SvgRenderer_Render_SingleLine_WithHollowTriangleArrowhead_ProducesMarkerEnd`, and +`TriangleMarker_DimensionsDeriveFromNotationMetrics` assert that the hollow-triangle marker definition +contains `` and no ``, a hollow-triangle target line contains a `marker-end` +attribute, and triangle marker dimensions and points match `NotationMetrics`. + +Tests `DiamondMarker_DimensionsDeriveFromNotationMetrics` and +`SvgRenderer_Render_SingleLine_WithDiamondArrowhead_ProducesDiamondMarker` assert that diamond marker +dimensions and points match `NotationMetrics` and that the rendered SVG contains the +`line-end-hollow-diamond` id. + +Test `SvgRenderer_Render_SingleLine_WithOpenCrossbarArrowhead_ProducesOpenCrossbarMarker` asserts that +the rendered SVG contains the `line-end-hollow-triangle-crossbar` id. + +The diamond and crossbar assertions now check that the connector path element carries the marker +reference itself — `marker-start="url(#line-end-hollow-diamond)"` for the source end and +`marker-end="url(#line-end-hollow-triangle-crossbar)"` for the target end — rather than only that the +marker id appears somewhere in the document. This prevents a false pass if the marker is defined in +`` but no longer referenced by the connector. + +**Covers**: `Rendering-Svg-SvgRenderer-EndMarkers`, +`Rendering-Svg-SvgRenderer-EndMarkersOpenChevronReference`, +`Rendering-Svg-SvgRenderer-HollowTriangleEndMarkers`, +`Rendering-Svg-SvgRenderer-HollowTriangleEndMarkerReference`, +`Rendering-Svg-SvgRenderer-TriangleEndMarkerMetrics`, +`Rendering-Svg-SvgRenderer-DiamondEndMarkers`, +`Rendering-Svg-SvgRenderer-DiamondEndMarkerReference`, +`Rendering-Svg-SvgRenderer-CrossbarEndMarkers`. + +## Requirements Coverage + +- **`Rendering-Svg-SvgRenderer-ImplementsIRenderer`**: + `SvgRenderer_Render_SingleBox_ProducesSvgDocument` +- **`Rendering-Svg-SvgRenderer-MediaType`**: `SvgRenderer_Render_SingleBox_ProducesSvgDocument` +- **`Rendering-Svg-SvgRenderer-DefaultExtension`**: + `SvgRenderer_Render_SingleBox_ProducesSvgDocument` +- **`Rendering-Svg-SvgRenderer-RenderDocument`**: + `SvgRenderer_Render_EmptyTree_ProducesSvgDocument` +- **`Rendering-Svg-SvgRenderer-RenderEmptyTree`**: + `SvgRenderer_Render_EmptyTree_ProducesSvgDocument` +- **`Rendering-Svg-SvgRenderer-RenderBox`**: `SvgRenderer_Render_SingleBox_ProducesRectElement` +- **`Rendering-Svg-SvgRenderer-RenderBoxRoundedCorners`**: + `SvgRenderer_Render_BoxRoundedRectangle_ProducesRxAttribute` +- **`Rendering-Svg-SvgRenderer-RenderBoxCompartments`**: + `SvgRenderer_Render_BoxWithCompartment_ProducesLineAndText` +- **`Rendering-Svg-SvgRenderer-RenderLabel`**: + `SvgRenderer_Render_SingleLabel_ProducesTextElement` +- **`Rendering-Svg-SvgRenderer-RenderLabelBold`**: + `SvgRenderer_Render_LabelWithBold_ProducesBoldAttribute` +- **`Rendering-Svg-SvgRenderer-RenderLabelItalic`**: + `SvgRenderer_Render_LabelWithItalic_ProducesItalicAttribute` +- **`Rendering-Svg-SvgRenderer-RenderLabelEscaping`**: + `SvgRenderer_Render_LabelWithXmlSpecialCharacters_ProducesWellFormedEscapedSvg` +- **`Rendering-Svg-SvgRenderer-RenderLine`**: `SvgRenderer_Render_SingleLine_ProducesPathElement` +- **`Rendering-Svg-SvgRenderer-RenderLineRoundedCorners`**: + `SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath` +- **`Rendering-Svg-SvgRenderer-RenderLineDashed`**: + `SvgRenderer_Render_SingleLine_Dashed_ProducesDashArray` +- **`Rendering-Svg-SvgRenderer-RenderLineMidpointLabel`**: + `SvgRenderer_Render_LineWithMidpointLabel_ProducesTextElement` +- **`Rendering-Svg-SvgRenderer-RenderNodeKinds`**: `SvgRenderer_Render_SinglePort_ProducesRect` +- **`Rendering-Svg-SvgRenderer-RenderBadge`**: + `SvgRenderer_Render_SingleBadge_FilledCircle_ProducesCircle` +- **`Rendering-Svg-SvgRenderer-RenderBand`**: `SvgRenderer_Render_SingleBand_ProducesRect` +- **`Rendering-Svg-SvgRenderer-RenderLifeline`**: + `SvgRenderer_Render_SingleLifeline_ProducesRectAndLine` +- **`Rendering-Svg-SvgRenderer-RenderActivation`**: + `SvgRenderer_Render_SingleActivation_ProducesRect` +- **`Rendering-Svg-SvgRenderer-RenderGrid`**: `SvgRenderer_Render_SingleGrid_ProducesRects` +- **`Rendering-Svg-SvgRenderer-EndMarkers`**: `OpenChevron_IsDefinedAsPolyline` +- **`Rendering-Svg-SvgRenderer-EndMarkersOpenChevronReference`**: + `OpenChevronLine_ReferencesOpenChevronMarker` +- **`Rendering-Svg-SvgRenderer-HollowTriangleEndMarkers`**: + `HollowTriangle_IsDefinedAsClosedPolygon` +- **`Rendering-Svg-SvgRenderer-HollowTriangleEndMarkerReference`**: + `SvgRenderer_Render_SingleLine_WithHollowTriangleArrowhead_ProducesMarkerEnd` +- **`Rendering-Svg-SvgRenderer-TriangleEndMarkerMetrics`**: + `TriangleMarker_DimensionsDeriveFromNotationMetrics` +- **`Rendering-Svg-SvgRenderer-DiamondEndMarkers`**: + `DiamondMarker_DimensionsDeriveFromNotationMetrics` +- **`Rendering-Svg-SvgRenderer-DiamondEndMarkerReference`**: + `SvgRenderer_Render_SingleLine_WithDiamondArrowhead_ProducesDiamondMarker` +- **`Rendering-Svg-SvgRenderer-CrossbarEndMarkers`**: + `SvgRenderer_Render_SingleLine_WithOpenCrossbarArrowhead_ProducesOpenCrossbarMarker` diff --git a/docs/verification/rendering/demo.md b/docs/verification/rendering/demo.md deleted file mode 100644 index 3bd52f0..0000000 --- a/docs/verification/rendering/demo.md +++ /dev/null @@ -1,111 +0,0 @@ -# Demo Unit Verification Design - -This document describes the unit-level verification strategy for the `Demo` class. - -## Verification Strategy - -The `Demo` unit is verified through unit tests that exercise each public method and constructor -in isolation. Because `Demo` has no external dependencies beyond the .NET base class library, no -mocking or stubbing is required. Tests supply controlled inputs and assert on returned values and -thrown exception types. - -Unit tests reside in `DemoTests.cs` within the `DemaConsulting.Rendering.Tests` -project. - -## Test Environment - -- **Framework**: xUnit v3 running under the .NET SDK -- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline -- **Mocking**: None required; `Demo` has no injectable dependencies -- **Isolation**: Each test method constructs its own `Demo` instance; no shared state between tests - -## Unit-Level Test Scenarios - -### Template-Demo-Greeting: DemoMethod Default Prefix Returns Greeting - -**Test**: `Demo_DemoMethod_DefaultPrefix_ReturnsGreeting` - -Constructs a `Demo` using the default constructor and calls `DemoMethod("World")`. Asserts the -return value is exactly `"Hello, World!"`. Verifies the core greeting format `"{prefix}, {name}!"` -under the default prefix `"Hello"`. - -### Template-Demo-Greeting: DemoMethod Custom Prefix Returns Greeting - -**Test**: `Demo_DemoMethod_CustomPrefix_ReturnsGreeting` - -Constructs a `Demo` with custom prefix `"Hi"` and calls `DemoMethod("Alice")`. Asserts the return -value is exactly `"Hi, Alice!"`. Verifies that a non-default prefix is correctly combined with the -caller-supplied name in the `"{prefix}, {name}!"` format. - -### Template-Demo-DefaultPrefix: DefaultPrefix Constant Is Hello - -**Test**: `Demo_DefaultPrefix_Read_IsHello` - -Reads the `Demo.DefaultPrefix` constant directly and asserts its value is `"Hello"`. Verifies -that the constant has not silently changed, protecting callers who depend on the default greeting -string. - -### Template-Demo-DefaultPrefix: Default Constructor Sets Default Prefix - -**Test**: `Demo_DefaultConstructor_WithNoArgs_SetsDefaultPrefix` - -Constructs a `Demo` using the default constructor and reads the `Prefix` property. Asserts the -property value equals `Demo.DefaultPrefix`. Verifies that the default constructor stores the -expected prefix constant, confirming the `"Hello"` default is propagated end-to-end. - -### Template-Demo-AcceptCustomPrefix: Constructor With Custom Prefix Sets Prefix - -**Test**: `Demo_Prefix_WithCustomConstruction_ReturnsCustomPrefix` - -Constructs a `Demo` with a specific custom prefix and reads the `Prefix` property. Asserts the -property value exactly matches the string passed at construction. Verifies that the constructor -accepts and stores a caller-specified prefix, and that the `Prefix` property exposes the -configured value correctly. - -### Template-Demo-ValidationNull-DemoMethod: DemoMethod Null Input Throws ArgumentNullException - -**Test**: `Demo_DemoMethod_NullInput_ThrowsArgumentNullException` - -Constructs a `Demo` with the default constructor and calls `DemoMethod(null)`. Asserts that -`ArgumentNullException` (not the base `ArgumentException`) is thrown. Verifies that the unit -explicitly rejects `null` with the precise exception subtype. - -### Template-Demo-ValidationEmpty-DemoMethod: DemoMethod Empty Input Throws ArgumentException - -**Test**: `Demo_DemoMethod_EmptyInput_ThrowsArgumentException` - -Constructs a `Demo` with the default constructor and calls `DemoMethod(string.Empty)`. Asserts -that `ArgumentException` (not `ArgumentNullException`) is thrown. This is the empty-string -boundary condition — distinct from the null case. - -### Template-Demo-ValidationNull-Constructor: Constructor Null Prefix Throws ArgumentNullException - -**Test**: `Demo_Constructor_NullPrefix_ThrowsArgumentNullException` - -Attempts to construct a `Demo` with a `null` prefix argument. Asserts that -`ArgumentNullException` (not the base `ArgumentException`) is thrown. Verifies that the -custom-prefix constructor explicitly rejects `null`. - -### Template-Demo-ValidationEmpty-Constructor: Constructor Empty Prefix Throws ArgumentException - -**Test**: `Demo_Constructor_EmptyPrefix_ThrowsArgumentException` - -Attempts to construct a `Demo` with `string.Empty` as the prefix. Asserts that -`ArgumentException` (not `ArgumentNullException`) is thrown. This is the empty-string boundary -condition for the constructor — distinct from the null case. - -## Requirements Coverage - -| Requirement ID | Test Scenario(s) | -|-------------------------------------------|-----------------------------------------------------------| -| Template-Demo-Greeting | DemoMethod Default Prefix Returns Greeting | -| Template-Demo-Greeting | DemoMethod Custom Prefix Returns Greeting | -| Template-Demo-DefaultPrefix | DefaultPrefix Constant Is Hello | -| Template-Demo-DefaultPrefix | Default Constructor Sets Default Prefix | -| Template-Demo-AcceptCustomPrefix | Constructor With Custom Prefix Sets Prefix | -| Template-Demo-Prefix | Constructor With Custom Prefix Sets Prefix | -| Template-Demo-Prefix | Default Constructor Sets Default Prefix | -| Template-Demo-ValidationNull-DemoMethod | DemoMethod Null Input Throws ArgumentNullException | -| Template-Demo-ValidationNull-Constructor | Constructor Null Prefix Throws ArgumentNullException | -| Template-Demo-ValidationEmpty-DemoMethod | DemoMethod Empty Input Throws ArgumentException | -| Template-Demo-ValidationEmpty-Constructor | Constructor Empty Prefix Throws ArgumentException | diff --git a/docs/verification/rendering/layout-graph.md b/docs/verification/rendering/layout-graph.md new file mode 100644 index 0000000..6dd1414 --- /dev/null +++ b/docs/verification/rendering/layout-graph.md @@ -0,0 +1,84 @@ +# Layout Graph Unit Verification + +Part of the Rendering Model Verification. + +This document describes the verification design for the layout-graph unit of the +`DemaConsulting.Rendering` system. It maps every layout-graph unit requirement to at least one named +test scenario so a reviewer can confirm coverage without reading the test code. The verification +strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is `DemaConsulting.Rendering.Tests` +(`LayoutGraphTests.cs`). + +## Layout Graph Unit Scenarios + +### AddNode appends and returns the node + +Test `AddNode_AppendsNodeAndReturnsIt` calls `AddNode` on a fresh graph and asserts that the graph +contains one node and that the returned node carries the requested id, width, and height. + +**Covers**: `Rendering-Model-LayoutGraph-AddNode`. + +### AddEdge appends an edge with endpoints + +Test `AddEdge_AppendsEdgeWithEndpoints` adds two nodes and an edge referencing them, then asserts that +the graph contains one edge whose `Source` and `Target` are the same node instances supplied. + +**Covers**: `Rendering-Model-LayoutGraph-AddEdge`. + +### Node carries per-element properties + +Test `Node_CarriesPerElementProperties` sets the `CoreOptions.Direction` property on a node and reads +it back, asserting that the read returns the value set on that node. + +**Covers**: `Rendering-Model-LayoutGraph-PerElementProperties`. + +### Container node holds nested children and a leaf reports none + +Tests `LayoutGraphNode_Children_ContainerNode_HoldsChildNodesAndEdges` and +`LayoutGraphNode_HasChildren_LeafNode_ReturnsFalse` populate a node's `Children` subgraph with two +nested nodes and an intra-container edge and assert the node reports `HasChildren`, its children and +contained edge are retrievable, and the edge references the nested endpoints; the companion test +asserts that a freshly-added node with no accessed `Children` reports `HasChildren` false, confirming +a leaf allocates no child subgraph. + +**Covers**: `Rendering-Model-LayoutGraph-ContainerNodes`. + +### Identifiers are scoped per container + +Tests `LayoutGraph_AddNode_ChildScope_AllowsIdReuseAcrossScopes` and +`LayoutGraph_AddNode_ChildScope_DuplicateId_ThrowsArgumentException` add a node named `x` inside two +different container scopes and assert both are accepted as distinct instances, then add a duplicate id +within one scope and assert it is rejected and not appended — confirming per-scope uniqueness with +cross-scope reuse. `LayoutGraph_AddEdge_ChildScope_AllowsEdgeIdReuseAcrossScopes` adds an edge with +the same id inside two sibling container scopes and asserts both are accepted as distinct edges, +confirming edge identifiers are scoped per container just as node identifiers are. + +**Covers**: `Rendering-Model-LayoutGraph-ScopedIdentifiers`. + +### Cross-container edge references a descendant node + +Test `LayoutGraphEdge_CrossContainer_ReferencingDescendant_ConstructibleAtRoot` adds, at the root +graph, an edge between a root-level leaf node and a node nested inside a container, then asserts the +edge is held at the root yet references the descendant endpoint and that the descendant is not itself +a root-level node. `LayoutGraphEdge_CrossContainer_BetweenSiblingContainers_ConstructibleAtRoot` adds, +at the root, an edge between descendant nodes living in two different sibling containers and asserts +the edge is held at the root yet references both descendants — together confirming a cross-container +edge is expressible at the lowest common ancestor, including the sibling-container case. + +**Covers**: `Rendering-Model-LayoutGraph-CrossContainerEdge`. + +## Requirements Coverage + +- **`Rendering-Model-LayoutGraph-AddNode`**: AddNode_AppendsNodeAndReturnsIt +- **`Rendering-Model-LayoutGraph-AddEdge`**: AddEdge_AppendsEdgeWithEndpoints +- **`Rendering-Model-LayoutGraph-PerElementProperties`**: Node_CarriesPerElementProperties +- **`Rendering-Model-LayoutGraph-ContainerNodes`**: + LayoutGraphNode_Children_ContainerNode_HoldsChildNodesAndEdges, + LayoutGraphNode_HasChildren_LeafNode_ReturnsFalse +- **`Rendering-Model-LayoutGraph-ScopedIdentifiers`**: + LayoutGraph_AddNode_ChildScope_AllowsIdReuseAcrossScopes, + LayoutGraph_AddNode_ChildScope_DuplicateId_ThrowsArgumentException, + LayoutGraph_AddEdge_ChildScope_AllowsEdgeIdReuseAcrossScopes +- **`Rendering-Model-LayoutGraph-CrossContainerEdge`**: + LayoutGraphEdge_CrossContainer_ReferencingDescendant_ConstructibleAtRoot, + LayoutGraphEdge_CrossContainer_BetweenSiblingContainers_ConstructibleAtRoot diff --git a/docs/verification/rendering/layout-tree.md b/docs/verification/rendering/layout-tree.md new file mode 100644 index 0000000..41b9f0a --- /dev/null +++ b/docs/verification/rendering/layout-tree.md @@ -0,0 +1,132 @@ +# Layout Tree Unit Verification + +Part of the Rendering Model Verification. + +This document describes the verification design for the layout-tree unit of the +`DemaConsulting.Rendering` system. It maps every layout-tree unit requirement to at least one named +test scenario so a reviewer can confirm coverage without reading the test code. The verification +strategy, test environment, and acceptance criteria are described in the +system verification document; the test project is `DemaConsulting.Rendering.Tests` +(`LayoutTests.cs`). + +## Layout Tree Unit Scenarios + +### Layout tree carries canvas and nodes + +Test `LayoutTree_Construction_StoresWidthHeightNodes` constructs a `LayoutTree` with explicit width, +height, and a single top-level node, then asserts that `Width`, `Height`, and `Nodes` return the +supplied values and that the single node is the same instance supplied. + +**Covers**: `Rendering-Model-LayoutTree-Canvas`. + +### Node coordinates are absolute + +Tests `LayoutBox_Coordinates_AreAbsolute`, `LayoutPort_Coordinates_AreAbsolute`, and +`LayoutLine_Waypoints_AreAbsolute` construct a box, a port, and a line at explicit positions and +assert that the stored coordinates equal the supplied values with no offset or transform applied. + +**Covers**: `Rendering-Model-LayoutTree-AbsoluteCoordinates`. + +### Box carries all fields and children + +Tests `LayoutBox_Construction_StoresAllFields` and `LayoutBox_Children_ContainsNestedNodes` construct +a box with all nine parameters non-default and a box with a port and a nested box as children, then +assert that every property is stored and that both heterogeneous children are retrievable in +insertion order. + +**Covers**: `Rendering-Model-LayoutTree-Box`. + +### Box depth is an integer + +Test `LayoutBox_Depth_IsInteger` constructs a box with `Depth` set to 3 and asserts that `Depth` is +stored as an `int` with value 3, confirming the depth-not-color invariant. + +**Covers**: `Rendering-Model-LayoutTree-DepthNotColor`. + +### Port carries all fields + +Test `LayoutPort_Construction_StoresAllFields` constructs a port with centre, side, and label set and +asserts that `CentreX`, `CentreY`, `Side`, and `Label` equal the supplied values. + +**Covers**: `Rendering-Model-LayoutTree-Port`. + +### Line carries all fields + +Test `LayoutLine_Construction_StoresAllFields` constructs a line with two waypoints, both end-marker +styles, a line style, and a midpoint label, and asserts that `Waypoints`, `SourceEnd`, `TargetEnd`, +`LineStyle`, and `MidpointLabel` equal the supplied values. + +**Covers**: `Rendering-Model-LayoutTree-Line`. + +### Label carries all fields + +Test `LayoutLabel_Construction_StoresAllFields` constructs a label with all eight parameters +non-default and asserts that `X`, `Y`, `MaxWidth`, `Text`, `Align`, `Weight`, `Style`, and `FontSize` +equal the supplied values. + +**Covers**: `Rendering-Model-LayoutTree-Label`. + +### Badge carries all fields + +Test `LayoutBadge_Construction_StoresAllFields` constructs a badge with centre, size, shape, and label +and asserts that `CentreX`, `CentreY`, `Size`, `Shape`, and `Label` equal the supplied values. + +**Covers**: `Rendering-Model-LayoutTree-Badge`. + +### Band carries all fields + +Test `LayoutBand_Construction_StoresAllFields` constructs a band with bounds, orientation, label, and +one child and asserts that `X`, `Y`, `Width`, `Height`, `Orientation`, `Label`, and `Children` equal +the supplied values. + +**Covers**: `Rendering-Model-LayoutTree-Band`. + +### Lifeline carries all fields + +Test `LayoutLifeline_Construction_StoresAllFields` constructs a lifeline with centre, extent, label, +and header dimensions and asserts that `CentreX`, `TopY`, `BottomY`, `Label`, `HeaderWidth`, and +`HeaderHeight` equal the supplied values. + +**Covers**: `Rendering-Model-LayoutTree-Lifeline`. + +### Activation carries all fields + +Test `LayoutActivation_Construction_StoresAllFields` constructs an activation with centre and vertical +extent and asserts that `CentreX`, `TopY`, and `BottomY` equal the supplied values. + +**Covers**: `Rendering-Model-LayoutTree-Activation`. + +### Grid carries rows and cells + +Test `LayoutGrid_Construction_StoresAllFields` constructs a grid with one header row containing one +cell and asserts the grid position, the row's `IsHeader` flag, and the cell's `Width`, `Height`, +`Text`, `Align`, and `ColSpan`. + +**Covers**: `Rendering-Model-LayoutTree-Grid`. + +### Geometry value types carry their fields + +Tests `Point2D_Construction_StoresXY` and `Rect_Construction_StoresAllFields` construct a point at a +known location and a rectangle with all four fields non-default, then assert each value type stores +and returns its supplied coordinates and bounds unchanged. + +**Covers**: `Rendering-Model-LayoutTree-Geometry`. + +## Requirements Coverage + +- **`Rendering-Model-LayoutTree-Canvas`**: LayoutTree_Construction_StoresWidthHeightNodes +- **`Rendering-Model-LayoutTree-AbsoluteCoordinates`**: LayoutBox_Coordinates_AreAbsolute, + LayoutPort_Coordinates_AreAbsolute, LayoutLine_Waypoints_AreAbsolute +- **`Rendering-Model-LayoutTree-Box`**: LayoutBox_Construction_StoresAllFields, + LayoutBox_Children_ContainsNestedNodes +- **`Rendering-Model-LayoutTree-DepthNotColor`**: LayoutBox_Depth_IsInteger +- **`Rendering-Model-LayoutTree-Port`**: LayoutPort_Construction_StoresAllFields +- **`Rendering-Model-LayoutTree-Line`**: LayoutLine_Construction_StoresAllFields +- **`Rendering-Model-LayoutTree-Label`**: LayoutLabel_Construction_StoresAllFields +- **`Rendering-Model-LayoutTree-Badge`**: LayoutBadge_Construction_StoresAllFields +- **`Rendering-Model-LayoutTree-Band`**: LayoutBand_Construction_StoresAllFields +- **`Rendering-Model-LayoutTree-Lifeline`**: LayoutLifeline_Construction_StoresAllFields +- **`Rendering-Model-LayoutTree-Activation`**: LayoutActivation_Construction_StoresAllFields +- **`Rendering-Model-LayoutTree-Grid`**: LayoutGrid_Construction_StoresAllFields +- **`Rendering-Model-LayoutTree-Geometry`**: Point2D_Construction_StoresXY, + Rect_Construction_StoresAllFields diff --git a/docs/verification/rendering/options.md b/docs/verification/rendering/options.md new file mode 100644 index 0000000..eb19fa5 --- /dev/null +++ b/docs/verification/rendering/options.md @@ -0,0 +1,47 @@ +# Options Unit Verification + +Part of the Rendering Model Verification. + +This document describes the verification design for the options unit of the +`DemaConsulting.Rendering` system. It maps every options unit requirement to at least one named test +scenario so a reviewer can confirm coverage without reading the test code. The verification strategy, +test environment, and acceptance criteria are described in the +system verification document; the test project is `DemaConsulting.Rendering.Tests` +(`PropertyHolderTests.cs`). + +## Options Unit Scenarios + +### Unset property returns default + +Test `Get_UnsetProperty_ReturnsDefault` reads a property from a fresh `PropertyHolder` without setting +it and asserts that the read returns the property's declared default value. + +**Covers**: `Rendering-Model-Options-Default`. + +### Set value is retrieved + +Test `Get_AfterSet_ReturnsStoredValue` sets a property to a value, then reads the same property and +asserts that the read returns the stored value rather than the default. + +**Covers**: `Rendering-Model-Options-StoreAndRetrieve`. + +### Contains reflects explicit set + +Test `Contains_ReflectsExplicitSet` queries `Contains` before and after setting a property and asserts +that it returns false before the set and true afterwards. + +**Covers**: `Rendering-Model-Options-Contains`. + +### TryGet reports unset and yields default + +Test `TryGet_UnsetProperty_ReturnsFalseAndDefault` calls `TryGet` for a property that has not been set +and asserts that it returns false and yields the declared default through its out parameter. + +**Covers**: `Rendering-Model-Options-TryGet`. + +## Requirements Coverage + +- **`Rendering-Model-Options-Default`**: Get_UnsetProperty_ReturnsDefault +- **`Rendering-Model-Options-StoreAndRetrieve`**: Get_AfterSet_ReturnsStoredValue +- **`Rendering-Model-Options-Contains`**: Contains_ReflectsExplicitSet +- **`Rendering-Model-Options-TryGet`**: TryGet_UnsetProperty_ReturnsFalseAndDefault diff --git a/docs/verification/rendering/rendering.md b/docs/verification/rendering/rendering.md index 18df8f7..478dfdd 100644 --- a/docs/verification/rendering/rendering.md +++ b/docs/verification/rendering/rendering.md @@ -1,109 +1,47 @@ -# System Verification Design +# Rendering Model Verification -This document describes the system-level verification strategy for the Rendering. +This document describes the system-level verification design for the `DemaConsulting.Rendering` +(rendering model) system and links to the per-unit verification documents for its three units. It +records the verification strategy, test environment, and acceptance criteria shared by every unit, and +maps each system-level requirement to at least one named test scenario. The detailed per-requirement +scenarios live in the unit documents: -## Verification Strategy +- Layout Tree Unit Verification +- Options Unit Verification +- Layout Graph Unit Verification -The Rendering system is verified through system-level integration tests that -exercise the library as a whole from the perspective of a consumer. Tests instantiate the library -using its public API and assert on observable outputs, without relying on knowledge of internal -implementation details. No mocking or stubbing is required at the system level — the entire -integrated system is exercised as it would be used by a real caller. +## Verification Strategy -System tests reside in `RenderingTests.cs` within the -`DemaConsulting.Rendering.Tests` project. +The rendering model is a pure data-and-configuration library with no I/O, so it is verified entirely +through in-process unit tests that construct the model types and assert on their observable state. +Each test constructs the type directly with known inputs and asserts that every field is stored and +retrieved unchanged, confirming the model's core invariants (absolute coordinates, depth-not-color, +default-then-override configuration). No mocking or stubbing is required because the model has no +dependencies to isolate. ## Test Environment -- **Framework**: xUnit v3 running under the .NET SDK -- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline -- **Dependencies**: No external services, databases, or network access required -- **Isolation**: Each test method constructs its own `Demo` instance; no shared state between tests - -## External Interface Simulation - -The system has no external interfaces requiring simulation. It is a pure in-process .NET library -with no I/O, network calls, or platform services. System tests call the public API directly -with controlled inputs and verify returned values and thrown exceptions. - -## System-Level Test Scenarios - -### Integration: Provides Expected Functionality - -**Test**: `Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting` - -Exercises end-to-end system behavior: constructs a `Demo` instance using the default constructor -and calls `DemoMethod` with a valid name. Asserts that the system produces the expected greeting -string `"Hello, System!"`, confirming that all components integrate correctly under default -configuration. - -### Customization: Handles Configuration Properly - -**Test**: `Rendering_SystemCustomization_CustomPrefix_ReturnsExpectedGreeting` - -Verifies that the system correctly propagates a custom prefix supplied at construction time. -Constructs a `Demo` instance with prefix `"Welcome"`, calls `DemoMethod` with a valid name, and -asserts the result is `"Welcome, Integration!"`. Confirms that the configuration path through all -integrated components functions as expected. - -### Validation: DemoMethod Null Input Throws ArgumentNullException - -**Test**: `Rendering_SystemValidation_DemoMethodNullInput_ThrowsArgumentNullException` - -Verifies that the system rejects a `null` argument to `DemoMethod` with `ArgumentNullException`. -Constructs a `Demo` instance with the default constructor and passes `null` to `DemoMethod`. -Confirms that the system boundary enforces the null-rejection contract. - -### Validation: DemoMethod Empty Input Throws ArgumentException - -**Test**: `Rendering_SystemValidation_DemoMethodEmptyInput_ThrowsArgumentException` - -Verifies that the system rejects an empty-string argument to `DemoMethod` with `ArgumentException`. -Constructs a `Demo` instance with the default constructor and passes `string.Empty` to `DemoMethod`. -Confirms that the system boundary enforces the empty-string rejection contract. - -### Validation: Constructor Null Prefix Throws ArgumentNullException - -**Test**: `Rendering_SystemValidation_ConstructorNullPrefix_ThrowsArgumentNullException` - -Verifies that the system rejects a `null` prefix argument at construction time with -`ArgumentNullException`. Attempts to construct a `Demo` instance with `null` as the prefix. -Confirms that the system boundary prevents invalid configuration from being established. - -### Validation: Constructor Empty Prefix Throws ArgumentException - -**Test**: `Rendering_SystemValidation_ConstructorEmptyPrefix_ThrowsArgumentException` - -Verifies that the system rejects an empty-string prefix argument at construction time with -`ArgumentException`. Attempts to construct a `Demo` instance with `string.Empty` as the prefix. -Confirms that the system boundary prevents empty-string configuration from being established. - -### Integration: Exposes Configured Prefix - -**Test**: `Rendering_SystemIntegration_CustomPrefix_ExposesPrefix` - -Verifies that the `Prefix` property exposes the prefix supplied at construction time. Constructs -a `Demo` instance with a custom prefix and reads the `Prefix` property. Confirms the system's -public API correctly surfaces the configured prefix to callers. +- **Framework**: xUnit v3 running under the .NET SDK. +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline. +- **Dependencies**: none; no external services, network, or filesystem access. +- **Isolation**: each test constructs its own model instances; there is no shared state. +- **Test project**: `DemaConsulting.Rendering.Tests` (`LayoutTests.cs`, `PropertyHolderTests.cs`, + `LayoutGraphTests.cs`). ## Acceptance Criteria -A system-level test run passes when all seven scenarios above pass without error or exception beyond -those explicitly asserted. Any unexpected exception, wrong exception type, or wrong return value -constitutes a failure. +A verification run passes when every scenario in this system document and in the three unit documents +passes without error or unexpected exception. Any wrong stored value, wrong type, or unexpected +exception constitutes a failure. + +## System Requirements Coverage -## Requirements Coverage +The system requirements are satisfied through the unit scenarios documented in the per-unit +verification files; the representative system-level scenarios are: -| Requirement ID | Test Scenario(s) | -|------------------------------------------|------------------------------------------------------------------| -| Template-Lib-Greeting | Integration: Provides Expected Functionality | -| Template-Lib-Greeting | Customization: Handles Configuration Properly | -| Template-Lib-GreetingFormat | Integration: Provides Expected Functionality | -| Template-Lib-GreetingFormat | Customization: Handles Configuration Properly | -| Template-Lib-DefaultPrefix | Integration: Provides Expected Functionality | -| Template-Lib-CustomPrefix | Customization: Handles Configuration Properly | -| Template-Lib-Prefix | Integration: Exposes Configured Prefix | -| Template-Lib-ValidationNull-DemoMethod | Validation: DemoMethod Null Input Throws ArgumentNullException | -| Template-Lib-ValidationNull-Constructor | Validation: Constructor Null Prefix Throws ArgumentNullException | -| Template-Lib-ValidationEmpty-DemoMethod | Validation: DemoMethod Empty Input Throws ArgumentException | -| Template-Lib-ValidationEmpty-Constructor | Validation: Constructor Empty Prefix Throws ArgumentException | +- **`Rendering-Model-LayoutTree`**: LayoutTree_Construction_StoresWidthHeightNodes, + LayoutBox_Construction_StoresAllFields (see Layout Tree Unit Verification) +- **`Rendering-Model-Configuration`**: Get_UnsetProperty_ReturnsDefault, Get_AfterSet_ReturnsStoredValue + (see Options Unit Verification) +- **`Rendering-Model-InputGraph`**: AddNode_AppendsNodeAndReturnsIt, AddEdge_AppendsEdgeWithEndpoints + (see Layout Graph Unit Verification) diff --git a/gallery.ps1 b/gallery.ps1 new file mode 100644 index 0000000..0c52878 --- /dev/null +++ b/gallery.ps1 @@ -0,0 +1,37 @@ +# gallery.ps1 +# +# PURPOSE: +# Regenerates the committed rendering gallery under docs/gallery/. The gallery +# showcase lives in the DemaConsulting.Rendering.Gallery test project; its facts +# render curated diagrams to files and assert the results are valid images. +# +# 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/gallery.md index. +# +# USAGE: +# pwsh ./gallery.ps1 + +$ErrorActionPreference = 'Stop' + +$repoRoot = $PSScriptRoot +$galleryDir = Join-Path $repoRoot 'docs/gallery' +$galleryProject = Join-Path $repoRoot 'test/DemaConsulting.Rendering.Gallery/DemaConsulting.Rendering.Gallery.csproj' + +New-Item -ItemType Directory -Force -Path $galleryDir | Out-Null + +Write-Host "Regenerating gallery into $galleryDir ..." +$env:RENDERING_GALLERY_DIR = $galleryDir +try { + # Generate from a single target framework: the gallery output is deterministic across TFMs, and + # running all frameworks in parallel would race on the shared docs/gallery output files. + dotnet test $galleryProject --configuration Release --framework net8.0 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} +finally { + Remove-Item Env:\RENDERING_GALLERY_DIR -ErrorAction SilentlyContinue +} + +Write-Host "Gallery regenerated successfully!" diff --git a/requirements.yaml b/requirements.yaml index 2d1d374..bdb54a2 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -1,9 +1,42 @@ --- -# Root requirements file - includes all system, unit, platform, and OTS requirements +# Root requirements file - includes all system, unit, platform, and OTS requirements. +# One requirements file per software system; each file carries the system-level +# requirements plus the unit-level requirements (as separate sections) that decompose them. includes: + # In-house software systems - docs/reqstream/rendering/rendering.yaml - - docs/reqstream/rendering/demo.yaml + - docs/reqstream/rendering/layout-tree.yaml + - docs/reqstream/rendering/options.yaml + - docs/reqstream/rendering/layout-graph.yaml - docs/reqstream/rendering/platform-requirements.yaml + - docs/reqstream/rendering-abstractions/rendering-abstractions.yaml + - docs/reqstream/rendering-abstractions/rendering-contracts.yaml + - docs/reqstream/rendering-abstractions/registries.yaml + - docs/reqstream/rendering-abstractions/theme.yaml + - docs/reqstream/rendering-abstractions/notation-metrics.yaml + - docs/reqstream/rendering-abstractions/box-metrics.yaml + - docs/reqstream/rendering-abstractions/connector-label-placer.yaml + - docs/reqstream/rendering-layout/rendering-layout.yaml + - docs/reqstream/rendering-layout/engine/engine.yaml + - docs/reqstream/rendering-layout/engine/orthogonal-edge-router.yaml + - docs/reqstream/rendering-layout/engine/containment-packer.yaml + - docs/reqstream/rendering-layout/engine/interconnection-layout-engine.yaml + - docs/reqstream/rendering-layout/engine/layered-pipeline.yaml + - docs/reqstream/rendering-layout/layered-layout-algorithm.yaml + - docs/reqstream/rendering-layout/edge-routing-option.yaml + - docs/reqstream/rendering-layout/connector-router.yaml + - 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/default-layout.yaml + - docs/reqstream/rendering-svg/rendering-svg.yaml + - docs/reqstream/rendering-svg/svg-renderer.yaml + - docs/reqstream/rendering-skia/rendering-skia.yaml + - docs/reqstream/rendering-skia/skia-raster-renderer.yaml + - docs/reqstream/rendering-skia/png-renderer.yaml + - docs/reqstream/rendering-skia/jpeg-renderer.yaml + - docs/reqstream/rendering-skia/webp-renderer.yaml + # OTS software items - docs/reqstream/ots/xunit.yaml - docs/reqstream/ots/reqstream.yaml - docs/reqstream/ots/buildmark.yaml diff --git a/src/DemaConsulting.Rendering.Abstractions/BoxMetrics.cs b/src/DemaConsulting.Rendering.Abstractions/BoxMetrics.cs new file mode 100644 index 0000000..62cfb7e --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/BoxMetrics.cs @@ -0,0 +1,53 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// Shared geometry helpers that compute box title-area and folder-tab heights from a +/// . Both the layout strategies and the renderers use these formulas so +/// that reserved space and drawn space stay consistent. +/// +public static class BoxMetrics +{ + /// + /// Computes the height of the folder tab drawn at the top-left of a + /// box. + /// + /// Theme providing font and padding metrics. + /// The tab height in logical pixels. + public static double FolderTabHeight(Theme theme) => + theme.FontSizeBody + 2.0 * theme.LabelPadding; + + /// + /// Computes the height of the title area of a box: the vertical space reserved at the top + /// for the optional keyword line and the bold name line. + /// + /// Theme providing font and padding metrics. + /// Whether the box has a name label. + /// Whether the box has a keyword line above the name. + /// The title-area height in logical pixels. + public static double TitleAreaHeight(Theme theme, bool hasLabel, bool hasKeyword) + { + if (!hasLabel && !hasKeyword) + { + return 0.0; + } + + var height = theme.LabelPadding; + if (hasKeyword) + { + height += theme.FontSizeBody + theme.LabelPadding; + } + + if (hasLabel) + { + height += theme.FontSizeTitle + theme.LabelPadding; + } + + return height; + } +} diff --git a/src/DemaConsulting.Rendering.Abstractions/ConnectorLabelPlacer.cs b/src/DemaConsulting.Rendering.Abstractions/ConnectorLabelPlacer.cs new file mode 100644 index 0000000..5e2b63f --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/ConnectorLabelPlacer.cs @@ -0,0 +1,180 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// Computes non-overlapping screen positions for connector (midpoint) labels. +/// +/// +/// Each labelled line prefers the midpoint of its longest segment (an open run between boxes), but +/// when two labels would land on top of each other — for example where two connectors cross — the +/// placer falls back to a shorter segment or nudges the label perpendicular to its segment until it +/// no longer collides with an already-placed label. Lines are processed in the supplied order so the +/// result is deterministic. Both the SVG and PNG renderers share this logic so their label layouts +/// match. +/// +public static class ConnectorLabelPlacer +{ + /// Approximate width of one character as a fraction of the font size. + private const double CharWidthFactor = 0.6; + + /// Label box height as a multiple of the font size (cap height plus padding). + private const double HeightFactor = 1.3; + + /// Extra clearance, in logical pixels, added around each label box when testing overlap. + private const double Gap = 2.0; + + /// + /// Computes a label position for every line that has a . + /// + /// The lines to place labels for, in render order. + /// Body font size, in logical pixels, used to estimate label box sizes. + /// + /// A dictionary mapping each labelled line to its chosen (X, Y) label centre in logical pixels. + /// Lines without a label are omitted. + /// + public static IReadOnlyDictionary Place( + IEnumerable lines, + double fontSize) + { + ArgumentNullException.ThrowIfNull(lines); + + var placed = new List(); + var result = new Dictionary(); + + foreach (var line in lines) + { + if (line.MidpointLabel is null || line.Waypoints.Count == 0) + { + continue; + } + + var halfWidth = (EstimateTextWidth(line.MidpointLabel, fontSize) / 2.0) + Gap; + var halfHeight = (fontSize * HeightFactor / 2.0) + Gap; + + var position = ChoosePosition(line.Waypoints, halfWidth, halfHeight, placed); + placed.Add(new Bounds(position.X - halfWidth, position.Y - halfHeight, position.X + halfWidth, position.Y + halfHeight)); + result[line] = position; + } + + return result; + } + + /// Estimates the rendered width of a label string in logical pixels. + /// The label text. + /// Font size in logical pixels. + /// The approximate text width. + private static double EstimateTextWidth(string text, double fontSize) => + text.Length * fontSize * CharWidthFactor; + + /// + /// Selects a label position for a single line, preferring the midpoint of the longest segment and + /// falling back to shorter segments or perpendicular nudges to avoid overlapping placed labels. + /// + /// The line's waypoints. + /// Half the label box width (including gap). + /// Half the label box height (including gap). + /// Boxes of labels already placed. + /// The chosen label centre. + private static (double X, double Y) ChoosePosition( + IReadOnlyList waypoints, + double halfWidth, + double halfHeight, + List placed) + { + if (waypoints.Count == 1) + { + return (waypoints[0].X, waypoints[0].Y); + } + + // Segment midpoints ordered by descending length (longest, most-open run first). + var segments = new List<(double Length, double X, double Y, double DirX, double DirY)>(); + for (var i = 0; i < waypoints.Count - 1; i++) + { + var a = waypoints[i]; + var b = waypoints[i + 1]; + var dx = b.X - a.X; + var dy = b.Y - a.Y; + var length = Math.Sqrt((dx * dx) + (dy * dy)); + var dirX = length > 1e-9 ? dx / length : 0.0; + var dirY = length > 1e-9 ? dy / length : 0.0; + segments.Add((length, (a.X + b.X) / 2.0, (a.Y + b.Y) / 2.0, dirX, dirY)); + } + + segments.Sort((p, q) => q.Length.CompareTo(p.Length)); + + // First pass: take the first segment midpoint that does not collide. + var firstClear = segments + .Where(seg => !Collides(seg.X, seg.Y, halfWidth, halfHeight, placed)) + .Select(seg => ((double X, double Y)?)(seg.X, seg.Y)) + .FirstOrDefault(); + if (firstClear is not null) + { + return firstClear.Value; + } + + // Second pass: nudge along the longest segment's perpendicular until clear. + var best = segments[0]; + var perpX = -best.DirY; + var perpY = best.DirX; + var step = (halfHeight * 2.0) + Gap; + for (var k = 1; k <= 4; k++) + { + var offset = step * k; + if (!Collides(best.X + (perpX * offset), best.Y + (perpY * offset), halfWidth, halfHeight, placed)) + { + return (best.X + (perpX * offset), best.Y + (perpY * offset)); + } + + if (!Collides(best.X - (perpX * offset), best.Y - (perpY * offset), halfWidth, halfHeight, placed)) + { + return (best.X - (perpX * offset), best.Y - (perpY * offset)); + } + } + + // Final fallback: drop the label just beneath every placed label so it is guaranteed not to + // overlap, preserving the documented no-overlap guarantee even when the perpendicular nudges + // above are exhausted (for example where many connectors cross at a single point). Because a + // clear segment was not found in the first pass, `placed` is guaranteed to be non-empty here. + var clearBottom = placed.Max(r => r.Bottom); + return (best.X, clearBottom + halfHeight + Gap); + } + + /// Tests whether a candidate label box overlaps any already-placed box. + /// Candidate box centre X. + /// Candidate box centre Y. + /// Half the candidate box width. + /// Half the candidate box height. + /// Boxes already placed. + /// if the candidate overlaps a placed box. + private static bool Collides(double centreX, double centreY, double halfWidth, double halfHeight, List placed) + { + var left = centreX - halfWidth; + var top = centreY - halfHeight; + var right = centreX + halfWidth; + var bottom = centreY + halfHeight; + foreach (var r in placed) + { + if (left < r.Right && right > r.Left && top < r.Bottom && bottom > r.Top) + { + return true; + } + } + + return false; + } + + /// + /// An axis-aligned bounding box used only for label overlap tests. Distinct from the public + /// geometry : this type is expressed as edge coordinates + /// (left/top/right/bottom) rather than position-and-size, which keeps the overlap arithmetic + /// direct. + /// + /// Left edge. + /// Top edge. + /// Right edge. + /// Bottom edge. + private readonly record struct Bounds(double Left, double Top, double Right, double Bottom); +} diff --git a/src/DemaConsulting.Rendering.Abstractions/DemaConsulting.Rendering.Abstractions.csproj b/src/DemaConsulting.Rendering.Abstractions/DemaConsulting.Rendering.Abstractions.csproj new file mode 100644 index 0000000..6ac04fd --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/DemaConsulting.Rendering.Abstractions.csproj @@ -0,0 +1,84 @@ + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + + DemaConsulting.Rendering.Abstractions + 0.0.0 + DEMA Consulting + DEMA Consulting + Service-provider interfaces (layout algorithm and renderer contracts, theme, and options) for the DEMA Consulting rendering library + MIT + https://github.com/demaconsulting/Rendering + https://github.com/demaconsulting/Rendering + README.md + Icon.png + rendering;layout;dotnet-library + Copyright DEMA Consulting + Rendering Abstractions + Rendering + + + true + snupkg + true + true + true + + + true + true + true + true + true + latest + + + true + + + true + $(PackageId) + $(Version) + Organization: $(Company) + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/DemaConsulting.Rendering.Abstractions/ILayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Abstractions/ILayoutAlgorithm.cs new file mode 100644 index 0000000..3f6e022 --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/ILayoutAlgorithm.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// A pluggable layout algorithm: the high-level, ELK-inspired extension point. An algorithm consumes +/// an unplaced plus and produces a placed +/// ready for an . Additional diagram families (tree, +/// force, packing, and so on) are introduced purely additively by implementing this interface and +/// registering the implementation; no existing contract changes. +/// +public interface ILayoutAlgorithm +{ + /// + /// Gets the stable identifier used to select this algorithm (for example layered). Matches + /// the value read from . + /// + string Id { get; } + + /// + /// Computes a placed layout for using the supplied options. + /// + /// The unplaced input graph. + /// + /// Configuration for this run. Properties the algorithm does not understand are ignored, so + /// callers may pass options intended for other algorithms without error. + /// + /// A fully-placed with absolute coordinates and routed edges. + /// + /// Thrown when or is . + /// + LayoutTree Apply(LayoutGraph graph, LayoutOptions options); +} diff --git a/src/DemaConsulting.Rendering.Abstractions/IRenderer.cs b/src/DemaConsulting.Rendering.Abstractions/IRenderer.cs new file mode 100644 index 0000000..890ce60 --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/IRenderer.cs @@ -0,0 +1,36 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// Low-level renderer: converts one to one output stream. +/// Implementations must be pure, stateless, and must not perform filesystem access. +/// +public interface IRenderer +{ + /// Gets the MIME media type produced by this renderer. + string MediaType { get; } + + /// Gets the default file extension (including leading dot) produced by this renderer. + string DefaultExtension { get; } + + /// + /// Gets every file extension (each including a leading dot, lower-case) this renderer produces. + /// The list always contains and lets consumers register and + /// resolve a renderer by output filename (for example a renderer that emits both + /// .jpg and .jpeg). + /// + IReadOnlyList FileExtensions { get; } + + /// + /// Renders the layout tree and writes the output to . + /// + /// The layout tree describing all nodes to render. + /// Render options including theme and scale. + /// Destination stream that receives all rendered bytes. + void Render(LayoutTree layout, RenderOptions options, Stream output); +} diff --git a/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmRegistry.cs b/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmRegistry.cs new file mode 100644 index 0000000..2b40979 --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/LayoutAlgorithmRegistry.cs @@ -0,0 +1,69 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Collections.ObjectModel; + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// A lookup of implementations keyed by . +/// Consumers register the algorithms they wish to make available (for example the bundled +/// layered algorithm) and resolve one by identifier at layout time. This is the ELK-style +/// algorithm-provider service; it is not thread-safe for concurrent registration. +/// +public sealed class LayoutAlgorithmRegistry +{ + private readonly Dictionary _algorithms = + new(StringComparer.Ordinal); + + /// Gets the identifiers of all registered algorithms. + public ReadOnlyCollection Ids => new([.. _algorithms.Keys]); + + /// + /// Registers an algorithm, replacing any previously-registered algorithm with the same identifier. + /// + /// The algorithm to register. + /// This registry, to support fluent chaining. + /// Thrown when is . + public LayoutAlgorithmRegistry Register(ILayoutAlgorithm algorithm) + { + ArgumentNullException.ThrowIfNull(algorithm); + _algorithms[algorithm.Id] = algorithm; + return this; + } + + /// Determines whether an algorithm with the given identifier is registered. + /// The algorithm identifier. + /// when a matching algorithm is registered; otherwise . + /// Thrown when is . + public bool Contains(string id) + { + ArgumentNullException.ThrowIfNull(id); + return _algorithms.ContainsKey(id); + } + + /// Attempts to resolve the algorithm registered under . + /// The algorithm identifier. + /// When this method returns , the resolved algorithm. + /// when a matching algorithm is registered; otherwise . + /// Thrown when is . + public bool TryResolve(string id, out ILayoutAlgorithm? algorithm) + { + ArgumentNullException.ThrowIfNull(id); + return _algorithms.TryGetValue(id, out algorithm); + } + + /// Resolves the algorithm registered under . + /// The algorithm identifier. + /// The resolved algorithm. + /// Thrown when is . + /// Thrown when no algorithm is registered under . + public ILayoutAlgorithm Resolve(string id) + { + ArgumentNullException.ThrowIfNull(id); + return _algorithms.TryGetValue(id, out var algorithm) + ? algorithm + : throw new KeyNotFoundException($"No layout algorithm is registered with id '{id}'."); + } +} diff --git a/src/DemaConsulting.Rendering.Abstractions/NamespaceDoc.cs b/src/DemaConsulting.Rendering.Abstractions/NamespaceDoc.cs new file mode 100644 index 0000000..10359e8 --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/NamespaceDoc.cs @@ -0,0 +1,26 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// The service-provider interface (SPI) tier: the pluggable and +/// contracts, their registries, and the that sit between +/// the diagram model and the concrete layout and render implementations. +/// +/// +/// +/// It defines the contract (turn an unplaced LayoutGraph into +/// a placed LayoutTree) and the contract (draw a LayoutTree to +/// an output format), the and +/// that resolve them by identifier, and the (with the built-in +/// ) and render options that style output. Implement these contracts to add a +/// new layout algorithm or renderer additively — the bundled ones live in +/// DemaConsulting.Rendering.Layout, DemaConsulting.Rendering.Svg, and +/// DemaConsulting.Rendering.Skia. +/// +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.Rendering.Abstractions/NotationMetrics.cs b/src/DemaConsulting.Rendering.Abstractions/NotationMetrics.cs new file mode 100644 index 0000000..a77a748 --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/NotationMetrics.cs @@ -0,0 +1,237 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// A single vertex of an end-marker decoration, expressed in tip-relative notation units (before the +/// renderer's uniform scale is applied). +/// +/// +/// The coordinate frame is shared by both renderers so that the SVG marker and the PNG path describe +/// the identical shape: is the distance measured back from the line endpoint (the +/// tip) into the line, and is the perpendicular offset. A negative +/// places the vertex ahead of the endpoint (e.g. a triangle apex that overshoots +/// the tip by ). +/// +/// Distance back from the tip along the line, in notation units. +/// Perpendicular offset from the line, in notation units. +public readonly record struct MarkerVertex(double Along, double Across); + +/// +/// The single home for all intrinsic, theme-independent notation geometry shared by the SVG and PNG +/// renderers: end-marker (arrowhead) shapes and sizes, port squares, folder-tab proportions, note +/// dog-ear folds, rounded-rectangle corner scaling, badge fractions, and the label-background inset. +/// +/// +/// This class is the notation-geometry peer of : +/// every value is either a documented primitive notation constant or a documented derivation of those +/// primitives, so a geometry literal never appears more than once in the rendering path. The canonical +/// values are the historical SVG marker values (open/triangle 10x7 with refX 9, diamond 14x8 with +/// refX 13, circle 10x10 r4, bar 4x12); the PNG renderer derives its sizes from the same constants so +/// the two renderers draw the identical shape. +/// +public static class NotationMetrics +{ + // ── End-marker primitives (triangle family: open chevron, hollow/filled triangle) ────────── + + /// Along-line length of a triangular end marker (chevron, hollow/filled triangle). + public const double EndMarkerLength = 10.0; + + /// Across-line width (base span) of a triangular end marker. + public const double EndMarkerWidth = 7.0; + + /// + /// Along-line position, measured from the marker box origin, of the vertex that lands on the line + /// endpoint (the SVG refX). The triangle apex sits one + /// beyond it. + /// + public const double EndMarkerRefX = 9.0; + + /// Half of : the perpendicular offset of each base corner. + public const double EndMarkerHalfWidth = EndMarkerWidth / 2.0; + + /// + /// Distance the triangle apex overshoots the line endpoint, equal to the marker box length minus + /// its tip anchor ( - ). + /// + public const double EndMarkerTipOvershoot = EndMarkerLength - EndMarkerRefX; + + // ── Diamond primitives (hollow/filled diamond) ───────────────────────────────────────────── + + /// Along-line length of the diamond marker box. + public const double DiamondLength = 14.0; + + /// Across-line width of the diamond marker box. + public const double DiamondWidth = 8.0; + + /// + /// Along-line position, from the diamond box origin, of the far point that lands on the line + /// endpoint (the SVG refX). + /// + public const double DiamondRefX = 13.0; + + /// Half of : the perpendicular offset of each side point. + public const double DiamondHalfWidth = DiamondWidth / 2.0; + + /// Mid-length along-line position of the diamond side points ( / 2). + public const double DiamondMidX = DiamondLength / 2.0; + + /// + /// Along-line position, from the diamond box origin, of the near point, mirrored from the far + /// point about the mid-length ( - ). + /// + public const double DiamondNearX = DiamondLength - DiamondRefX; + + // ── Circle primitives ────────────────────────────────────────────────────────────────────── + + /// Radius of the circle end marker. + public const double CircleRadius = 4.0; + + /// + /// Square box that contains the circle marker; equal to the triangular marker length so circle and + /// triangle share one marker viewport. + /// + public const double CircleMarkerBox = EndMarkerLength; + + /// Centre of the circle marker box (half of ). + public const double CircleCenter = CircleMarkerBox / 2.0; + + /// + /// Along-line tip anchor of the circle marker: the far edge of the circle + /// ( + ) sits on the line endpoint. + /// + public const double CircleRefX = CircleCenter + CircleRadius; + + // ── Bar primitives ───────────────────────────────────────────────────────────────────────── + + /// Along-line thickness of the bar end marker (marker box length). + public const double BarAlong = 4.0; + + /// Across-line extent of the bar end marker (marker box height). + public const double BarAcross = 12.0; + + /// Half of : the bar's tip anchor along the line. + public const double BarHalfAlong = BarAlong / 2.0; + + /// Half of : the bar's perpendicular half-length. + public const double BarHalf = BarAcross / 2.0; + + // ── Crossbar (hollow triangle with crossbar) ─────────────────────────────────────────────── + + /// + /// Fraction of from the base at which the redefinition crossbar + /// crosses the triangle shaft. + /// + public const double CrossbarFraction = 0.7; + + /// Along-line position of the crossbar from the marker box origin ( x ). + public const double CrossbarX = CrossbarFraction * EndMarkerLength; + + // ── Port square ──────────────────────────────────────────────────────────────────────────── + + /// Half the side length of the square drawn for a port. + public const double PortHalfSize = 4.0; + + /// Full side length of the square drawn for a port (2 x ). + public const double PortSize = PortHalfSize * 2.0; + + // ── Folder tab ───────────────────────────────────────────────────────────────────────────── + + /// Maximum folder-tab width as a fraction of the box width. + public const double FolderTabMaxWidthFraction = 0.45; + + /// Minimum folder-tab width in logical pixels. + public const double FolderTabMinWidth = 60.0; + + /// Approximate character-width factor (relative to body font size) used to size the folder-tab label. + public const double FolderLabelCharWidthFactor = 0.55; + + // ── Note dog-ear fold ────────────────────────────────────────────────────────────────────── + + /// Note dog-ear fold size as a fraction of the box's shorter side. + public const double NoteFoldFraction = 0.25; + + /// Maximum note dog-ear fold size in logical pixels. + public const double NoteFoldMaxSize = 16.0; + + // ── Rounded rectangle ────────────────────────────────────────────────────────────────────── + + /// Factor applied to the theme corner radius for a rounded-rectangle box corner. + public const double RoundedRectCornerFactor = 2.0; + + // ── Badge fractions ──────────────────────────────────────────────────────────────────────── + + /// Inner (white) radius of a bullseye badge as a fraction of the badge radius. + public const double BadgeBullseyeInnerFraction = 1.0 / 3.0; + + /// Bar-badge half-length as a fraction of the badge radius. + public const double BadgeBarLengthFraction = 0.8; + + // ── Label background filter ──────────────────────────────────────────────────────────────── + + /// Negative inset (each side) of the SVG label-background filter region. + public const double LabelBgInset = 0.05; + + /// Total label-background filter extent (1 + 2 x ). + public const double LabelBgExtent = 1.0 + (2.0 * LabelBgInset); + + /// + /// Returns the rounded-rectangle corner radius for a box: the theme corner radius scaled by + /// . + /// + /// Theme providing the base line corner radius. + /// The rounded-rectangle corner radius in logical pixels. + public static double RoundedRectRadius(Theme theme) + { + ArgumentNullException.ThrowIfNull(theme); + return theme.LineCornerRadius * RoundedRectCornerFactor; + } + + /// + /// Returns the along-line length consumed by an end-marker decoration, used by the layout + /// strategies (to reserve a clean approach) and the renderers (to clamp the final corner radius). + /// + /// The end-marker style. + /// The along-line length in notation units; zero for . + public static double AlongLineLength(EndMarkerStyle style) => style switch + { + EndMarkerStyle.OpenChevron => EndMarkerLength, + EndMarkerStyle.HollowTriangle => EndMarkerLength, + EndMarkerStyle.HollowTriangleCrossbar => EndMarkerLength, + EndMarkerStyle.FilledArrow => EndMarkerLength, + EndMarkerStyle.HollowDiamond => DiamondLength, + EndMarkerStyle.FilledDiamond => DiamondLength, + EndMarkerStyle.Circle => CircleMarkerBox, + EndMarkerStyle.Bar => BarAlong, + _ => 0.0, + }; + + /// + /// Returns the triangle vertices (base corner, apex, base corner) in tip-relative notation units, + /// shared by the open chevron, hollow triangle, and filled arrow markers. + /// + /// The three triangle vertices in draw order. + public static IReadOnlyList TriangleVertices() => + [ + new MarkerVertex(EndMarkerRefX, -EndMarkerHalfWidth), + new MarkerVertex(-EndMarkerTipOvershoot, 0.0), + new MarkerVertex(EndMarkerRefX, EndMarkerHalfWidth), + ]; + + /// + /// Returns the diamond vertices (near, side, far, side) in tip-relative notation units, shared by + /// the hollow and filled diamond markers. The far point lands on the line endpoint. + /// + /// The four diamond vertices in draw order. + public static IReadOnlyList DiamondVertices() => + [ + new MarkerVertex(DiamondRefX - DiamondNearX, 0.0), + new MarkerVertex(DiamondRefX - DiamondMidX, -DiamondHalfWidth), + new MarkerVertex(0.0, 0.0), + new MarkerVertex(DiamondRefX - DiamondMidX, DiamondHalfWidth), + ]; +} diff --git a/src/DemaConsulting.Rendering.Abstractions/RenderOptions.cs b/src/DemaConsulting.Rendering.Abstractions/RenderOptions.cs new file mode 100644 index 0000000..7f5a2fd --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/RenderOptions.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// Options that control the rendering of a . +/// +/// +/// Only is required. , and +/// are optional (defaults 1.0, 96 and 0), so the +/// usual form is simply new RenderOptions(theme); supply the later arguments only to override a +/// default. +/// +/// Visual theme (colors, fonts, line metrics). +/// Uniform scale factor applied to all coordinates. Default is 1.0. +/// Output resolution in dots per inch. Default is 96. +/// Maximum nesting depth to render. 0 means unlimited. +public sealed record RenderOptions( + Theme Theme, + double Scale = 1.0, + double Dpi = 96.0, + int DepthLimit = 0); diff --git a/src/DemaConsulting.Rendering.Abstractions/RenderOutput.cs b/src/DemaConsulting.Rendering.Abstractions/RenderOutput.cs new file mode 100644 index 0000000..e899705 --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/RenderOutput.cs @@ -0,0 +1,23 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// A single rendered output stream with metadata. +/// +/// Suggested file name including extension (no path). +/// MIME media type of the output stream. +/// The rendered output data stream. +public sealed record RenderOutput( + string SuggestedFileName, + string MediaType, + Stream Data) +{ + /// + /// Gets non-fatal layout-quality warnings produced while laying out this view (e.g. connectors + /// that could not be routed without crossing a box). Empty when the layout is clean. + /// + public IReadOnlyList Warnings { get; init; } = []; +} diff --git a/src/DemaConsulting.Rendering.Abstractions/RendererRegistry.cs b/src/DemaConsulting.Rendering.Abstractions/RendererRegistry.cs new file mode 100644 index 0000000..88326ff --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/RendererRegistry.cs @@ -0,0 +1,140 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Collections.ObjectModel; + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// A lookup of implementations indexed by both +/// and every file extension a renderer produces. Consumers +/// register the renderers they wish to make available (for example the bundled SVG, PNG, JPEG, and +/// WEBP renderers) and resolve one by media type or by output filename extension at render time. It is +/// not thread-safe for concurrent registration. +/// +public sealed class RendererRegistry +{ + private readonly Dictionary _byMediaType = + new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary _byExtension = + new(StringComparer.OrdinalIgnoreCase); + + /// Gets the media types of all registered renderers. + public ReadOnlyCollection MediaTypes => new([.. _byMediaType.Keys]); + + /// Gets the file extensions (each including a leading dot) of all registered renderers. + public ReadOnlyCollection FileExtensions => new([.. _byExtension.Keys]); + + /// + /// Registers a renderer under its media type and each of its file extensions, replacing any + /// previously-registered renderer that claimed the same media type or extension. + /// + /// The renderer to register. + /// This registry, to support fluent chaining. + /// Thrown when is . + public RendererRegistry Register(IRenderer renderer) + { + ArgumentNullException.ThrowIfNull(renderer); + + // If a renderer was previously registered under this media type, purge any extension + // aliases that still point to it. Otherwise a re-registration whose new renderer claims + // fewer or different extensions would leave stale aliases resolving to the old renderer. + if (_byMediaType.TryGetValue(renderer.MediaType, out var previous) && + !ReferenceEquals(previous, renderer)) + { + var staleExtensions = _byExtension + .Where(pair => ReferenceEquals(pair.Value, previous)) + .Select(pair => pair.Key) + .ToList(); + foreach (var extension in staleExtensions) + { + _byExtension.Remove(extension); + } + } + + _byMediaType[renderer.MediaType] = renderer; + foreach (var extension in renderer.FileExtensions) + { + _byExtension[Normalize(extension)] = renderer; + } + + return this; + } + + /// Determines whether a renderer for the given media type is registered. + /// The renderer media type. + /// when a matching renderer is registered; otherwise . + /// Thrown when is . + public bool Contains(string mediaType) + { + ArgumentNullException.ThrowIfNull(mediaType); + return _byMediaType.ContainsKey(mediaType); + } + + /// Determines whether a renderer for the given file extension is registered. + /// The file extension, with or without a leading dot (case-insensitive). + /// when a matching renderer is registered; otherwise . + /// Thrown when is . + public bool ContainsExtension(string extension) + { + ArgumentNullException.ThrowIfNull(extension); + return _byExtension.ContainsKey(Normalize(extension)); + } + + /// Attempts to resolve the renderer registered for . + /// The renderer media type. + /// When this method returns , the resolved renderer. + /// when a matching renderer is registered; otherwise . + /// Thrown when is . + public bool TryResolve(string mediaType, out IRenderer? renderer) + { + ArgumentNullException.ThrowIfNull(mediaType); + return _byMediaType.TryGetValue(mediaType, out renderer); + } + + /// Attempts to resolve the renderer registered for . + /// The file extension, with or without a leading dot (case-insensitive). + /// When this method returns , the resolved renderer. + /// when a matching renderer is registered; otherwise . + /// Thrown when is . + public bool TryResolveByExtension(string extension, out IRenderer? renderer) + { + ArgumentNullException.ThrowIfNull(extension); + return _byExtension.TryGetValue(Normalize(extension), out renderer); + } + + /// Resolves the renderer registered for . + /// The renderer media type. + /// The resolved renderer. + /// Thrown when is . + /// Thrown when no renderer is registered for . + public IRenderer Resolve(string mediaType) + { + ArgumentNullException.ThrowIfNull(mediaType); + return _byMediaType.TryGetValue(mediaType, out var renderer) + ? renderer + : throw new KeyNotFoundException($"No renderer is registered for media type '{mediaType}'."); + } + + /// Resolves the renderer registered for the given output file extension. + /// The file extension, with or without a leading dot (case-insensitive). + /// The resolved renderer. + /// Thrown when is . + /// Thrown when no renderer is registered for . + public IRenderer ResolveByExtension(string extension) + { + ArgumentNullException.ThrowIfNull(extension); + var normalized = Normalize(extension); + return _byExtension.TryGetValue(normalized, out var renderer) + ? renderer + : throw new KeyNotFoundException($"No renderer is registered for file extension '{normalized}'."); + } + + private static string Normalize(string extension) + { + var trimmed = extension.Trim(); + return trimmed.StartsWith('.') ? trimmed.ToLowerInvariant() : "." + trimmed.ToLowerInvariant(); + } +} diff --git a/src/DemaConsulting.Rendering.Abstractions/Theme.cs b/src/DemaConsulting.Rendering.Abstractions/Theme.cs new file mode 100644 index 0000000..7925699 --- /dev/null +++ b/src/DemaConsulting.Rendering.Abstractions/Theme.cs @@ -0,0 +1,113 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Abstractions; + +/// +/// Visual theme for rendering a diagram. +/// +/// +/// Font choice is not part of the theme; each renderer hardcodes its own typeface internally +/// to ensure consistent output across all platforms. +/// +/// +/// Hex colour strings indexed by nesting depth. Wraps if depth exceeds count. +/// Example: ["#FFFFFF", "#EEF4FF", "#D6E8FF"]. +/// +/// Hex colour used for all borders and lines. +/// Width of borders and lines in logical pixels. +/// Corner radius for orthogonal-line elbows. 0 = sharp; >0 = rounded. +/// Font size for title / heading text. +/// Font size for body / row text. +/// Internal padding between text and its bounding box. +/// Perpendicular step-off distance from a box face before a connector bends. +/// +/// Corner bend radius reserved for a connector's approach zone by +/// (layout approach reservation). It is not read by any renderer; renderers round connector elbows +/// using instead. +/// +/// +/// Safety margin (in logical pixels) added to a decorated connector end's required clean straight +/// approach, beyond the end-marker length plus one corner radius, so the rounded corner never intrudes +/// into the end decoration. +/// +public sealed record Theme( + IReadOnlyList DepthFillColors, + string StrokeColor, + double StrokeWidth, + double LineCornerRadius, + double FontSizeTitle, + double FontSizeBody, + double LabelPadding, + double ConnectorStub, + double BendRadius, + double CleanLegMargin) +{ + /// + /// Computes the connector approach zone: the clear distance a connector needs off a box face + /// before it can bend, combining the perpendicular stub, the corner bend radius, and the + /// caller-supplied connector clearance. + /// + /// Clearance kept between routed connectors and part boxes. + /// The required approach-zone distance in logical pixels. + public double ConnectorApproachZone(double connectorClearance) => + ConnectorStub + BendRadius + connectorClearance; + + /// + /// Gets the canvas/base background fill color (the depth-0 fill). Used to occlude connector lines + /// behind hollow (unfilled) enclosing end markers so the line does not show through the decoration. + /// + public string BackgroundColor => DepthFillColors[0]; +} + +/// +/// Built-in themes for common rendering scenarios. +/// +public static class Themes +{ + /// + /// Gets a light theme suitable for screen display. + /// + public static Theme Light { get; } = new( + DepthFillColors: ["#FFFFFF", "#EEF4FF", "#D6E8FF", "#C0D8F8"], + StrokeColor: "#1A1A2E", + StrokeWidth: 1.5, + LineCornerRadius: 4.0, + FontSizeTitle: 14.0, + FontSizeBody: 12.0, + LabelPadding: 6.0, + ConnectorStub: 8.0, + BendRadius: 4.0, + CleanLegMargin: 1.0); + + /// + /// Gets a dark theme suitable for dark-mode screen display. + /// + public static Theme Dark { get; } = new( + DepthFillColors: ["#1E1E2E", "#2A2A40", "#363650", "#424260"], + StrokeColor: "#C0C0D8", + StrokeWidth: 1.5, + LineCornerRadius: 4.0, + FontSizeTitle: 14.0, + FontSizeBody: 12.0, + LabelPadding: 6.0, + ConnectorStub: 8.0, + BendRadius: 4.0, + CleanLegMargin: 1.0); + + /// + /// Gets a print theme optimized for black-and-white output. + /// + public static Theme Print { get; } = new( + DepthFillColors: ["#FFFFFF", "#F0F0F0", "#E0E0E0", "#D0D0D0"], + StrokeColor: "#000000", + StrokeWidth: 1.0, + LineCornerRadius: 0.0, + FontSizeTitle: 12.0, + FontSizeBody: 10.0, + LabelPadding: 4.0, + ConnectorStub: 6.0, + BendRadius: 0.0, + CleanLegMargin: 1.0); +} diff --git a/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs b/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs new file mode 100644 index 0000000..995ef94 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/ConnectorRouter.cs @@ -0,0 +1,280 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout; + +/// +/// Describes one connector to be routed between two already-placed boxes, together with the styling +/// 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 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. +/// +/// End-marker style drawn at the target (arrival) end of the connector. +/// Stroke style (solid, dashed, dotted) applied to the routed connector. +/// Optional midpoint label carried onto the routed line; for an unlabelled connector. +/// +/// A carries no geometry of its own: source and target anchors are chosen by +/// from the boxes' current placement. The record intentionally models +/// only general diagram concepts (two endpoints plus line styling) and holds no domain-specific data. +/// +public sealed record Connection( + LayoutBox From, + LayoutBox To, + EndMarkerStyle TargetEnd = EndMarkerStyle.None, + LineStyle LineStyle = LineStyle.Solid, + string? Label = null); + +/// +/// Options controlling how routes connectors among placed boxes. +/// +/// +/// Routing style to apply, mirroring ELK's elk.edgeRouting. Defaults to +/// , the only shipped style; the switch grows additively +/// as new routers are implemented. +/// +/// +/// Minimum gap, in logical pixels, kept between routed segments and the boxes they steer around. +/// Defaults to 12.0. Callers can override this to reproduce a specific spacing (for example a +/// downstream adapter matching its historical output). +/// +/// +/// The default value mirrors the property default on , so a +/// caller that has not selected a routing style gets orthogonal routing either way. +/// +public sealed record ConnectorRouteOptions( + EdgeRouting EdgeRouting = EdgeRouting.Orthogonal, + double Clearance = 12.0); + +/// +/// Routes connectors among a set of already-placed boxes, producing one per +/// connection. This is the public routing-orchestration entry point: it picks boundary anchors facing +/// the other endpoint, builds the obstacle set from the remaining boxes, and defers the per-connector +/// path to the router selected by . +/// +/// +/// +/// The orchestration is deliberately model-agnostic: boxes are matched to their connections by +/// instance identity, and no domain concept (names, kinds, qualified references) enters the routing. +/// For each connection the two endpoint boxes are excluded from the obstacle set — a connector must +/// be free to leave and enter the boxes it joins — while every other box becomes a +/// obstacle the route steers around by . +/// +/// +/// Source and target anchors are chosen on the box faces that front each other, based on the boxes' +/// relative placement rather than the direction to a possibly far-off centre, and are aligned to the +/// overlap of the boxes on the shared edge. Connectors therefore leave and arrive on the sides the two +/// boxes actually present to each other, without wrapping back across a wide endpoint box. The chosen +/// side is passed to the underlying router so the connector exits and enters perpendicular to the edge. +/// +/// +/// Today the only supported style is , realized by the +/// library's internal orthogonal edge router. The dispatch is a single-arm switch structured so new +/// routing styles slot in additively without changing this contract. +/// +/// +/// +/// +/// // Two already-placed boxes and an intervening one that the connector must avoid. +/// var a = new LayoutBox(0, 0, 80, 40, "A", 0, BoxShape.Rectangle, [], []); +/// var mid = new LayoutBox(140, -10, 60, 80, "M", 0, BoxShape.Rectangle, [], []); +/// var b = new LayoutBox(260, 0, 80, 40, "B", 0, BoxShape.Rectangle, [], []); +/// var boxes = new[] { a, mid, b }; +/// +/// // Route A -> B (avoiding M) and A -> M, carrying the requested styling. +/// var connections = new[] +/// { +/// new Connection(a, b, EndMarkerStyle.FilledArrow), +/// new Connection(a, mid, EndMarkerStyle.HollowDiamond, LineStyle.Dashed, "owns"), +/// }; +/// +/// var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); +/// +/// // Drop the placed boxes and the routed connectors into a LayoutTree for a renderer. +/// var nodes = new List<LayoutNode>(); +/// nodes.AddRange(boxes); +/// nodes.AddRange(lines); +/// var tree = new LayoutTree(360, 80, nodes); +/// +/// +public static class ConnectorRouter +{ + /// + /// Routes every connection in among the placed + /// , returning one routed per connection in the + /// same order. + /// + /// + /// All placed boxes on the canvas. Boxes other than a connection's two endpoints act as obstacles + /// for that connection's route. + /// + /// The connectors to route, each naming a source and target box. + /// Routing options, including the routing style and obstacle clearance. + /// One routed per connection, in input order. + /// + /// Thrown when , , , + /// or any connection (or its / ) is + /// . + /// + /// + /// Thrown when names a style that has no shipped + /// router. + /// + public static IReadOnlyList Route( + IReadOnlyList boxes, + IReadOnlyList connections, + ConnectorRouteOptions options) + { + ArgumentNullException.ThrowIfNull(boxes); + ArgumentNullException.ThrowIfNull(connections); + ArgumentNullException.ThrowIfNull(options); + + var lines = new List(connections.Count); + foreach (var connection in connections) + { + lines.Add(Route(boxes, connection, options)); + } + + return lines; + } + + /// + /// Routes a single among the placed . + /// + /// + /// All placed boxes on the canvas. Every box except the connection's two endpoints acts as an + /// obstacle for this route. + /// + /// The connector to route. + /// Routing options, including the routing style and obstacle clearance. + /// The routed connector as a carrying the connection's styling. + /// + /// Thrown when , , , + /// or the connection's / is + /// . + /// + /// + /// Thrown when names a style that has no shipped + /// router. + /// + public static LayoutLine Route( + IReadOnlyList boxes, + Connection connection, + ConnectorRouteOptions options) + { + ArgumentNullException.ThrowIfNull(boxes); + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(connection.From); + ArgumentNullException.ThrowIfNull(connection.To); + + var from = connection.From; + var to = connection.To; + + // Pick anchors on the faces the two boxes actually present to each other, based on their + // relative placement. Using the direction to the other box's centre misfires for wide boxes + // (whose centre can sit far past the near endpoint), forcing the connector to wrap back across a + // box; deriving the facing sides from the box rectangles avoids that. + var (source, sourceSide, target, targetSide) = FacingAnchors(from, to); + + // The obstacle set is every box except this connection's own endpoints, matched by instance + // identity. The connector must be free to leave and enter the boxes it joins. + var obstacles = new List(boxes.Count); + foreach (var box in boxes) + { + if (ReferenceEquals(box, from) || ReferenceEquals(box, to)) + { + continue; + } + + obstacles.Add(new Rect(box.X, box.Y, box.Width, box.Height)); + } + + var waypoints = RouteWaypoints(options.EdgeRouting, source, target, obstacles, options.Clearance, sourceSide, targetSide); + + return new LayoutLine( + Waypoints: waypoints, + SourceEnd: EndMarkerStyle.None, + TargetEnd: connection.TargetEnd, + LineStyle: connection.LineStyle, + MidpointLabel: connection.Label); + } + + /// + /// Dispatches to the router realizing the requested style. + /// + private static IReadOnlyList RouteWaypoints( + EdgeRouting edgeRouting, + Point2D source, + Point2D target, + IReadOnlyList obstacles, + double clearance, + PortSide sourceSide, + PortSide targetSide) => edgeRouting switch + { + EdgeRouting.Orthogonal => + OrthogonalEdgeRouter.RouteWithStatus(source, target, obstacles, clearance, sourceSide, targetSide).Waypoints, + _ => throw new NotSupportedException($"Edge routing style '{edgeRouting}' has no shipped router."), + }; + + /// + /// Chooses boundary anchors on the two box faces that front each other, based on the boxes' + /// relative placement. The connector leaves and enters on the sides actually facing the other box — + /// the axis along which the boxes are more separated — and each anchor is aligned to the overlap of + /// the boxes on the shared edge, so the route stays short and never wraps back across an endpoint. + /// + /// The source box. + /// The target box. + /// The source anchor and side, and the target anchor and side. + private static (Point2D Source, PortSide SourceSide, Point2D Target, PortSide TargetSide) FacingAnchors( + LayoutBox from, LayoutBox to) + { + // Signed separation on each axis: positive when the boxes clear each other on that axis, + // negative when they overlap. Anchor on the axis with the greater separation so the connector + // spans the real gap between the boxes. + var horizontalGap = Math.Max(to.X - (from.X + from.Width), from.X - (to.X + to.Width)); + var verticalGap = Math.Max(to.Y - (from.Y + from.Height), from.Y - (to.Y + to.Height)); + + if (horizontalGap >= verticalGap) + { + // Left/right relationship: the left box anchors its right face, the right box its left face. + var fromIsLeft = from.X + (from.Width / 2.0) <= to.X + (to.Width / 2.0); + var y = AlignedCoordinate(from.Y, from.Y + from.Height, to.Y, to.Y + to.Height); + return fromIsLeft + ? (new Point2D(from.X + from.Width, Clamp(y, from.Y, from.Y + from.Height)), PortSide.Right, + new Point2D(to.X, Clamp(y, to.Y, to.Y + to.Height)), PortSide.Left) + : (new Point2D(from.X, Clamp(y, from.Y, from.Y + from.Height)), PortSide.Left, + new Point2D(to.X + to.Width, Clamp(y, to.Y, to.Y + to.Height)), PortSide.Right); + } + + // Top/bottom relationship: the upper box anchors its bottom face, the lower box its top face. + var fromIsAbove = from.Y + (from.Height / 2.0) <= to.Y + (to.Height / 2.0); + var x = AlignedCoordinate(from.X, from.X + from.Width, to.X, to.X + to.Width); + return fromIsAbove + ? (new Point2D(Clamp(x, from.X, from.X + from.Width), from.Y + from.Height), PortSide.Bottom, + new Point2D(Clamp(x, to.X, to.X + to.Width), to.Y), PortSide.Top) + : (new Point2D(Clamp(x, from.X, from.X + from.Width), from.Y), PortSide.Top, + new Point2D(Clamp(x, to.X, to.X + to.Width), to.Y + to.Height), PortSide.Bottom); + } + + /// + /// Returns a coordinate shared by the spans [, ] and + /// [, ]: the centre of their overlap when they overlap, + /// otherwise the midpoint of the gap between them. Anchors are clamped into each box afterwards, so + /// a shared coordinate keeps both ends aligned when the boxes overlap on the perpendicular axis. + /// + private static double AlignedCoordinate(double aLo, double aHi, double bLo, double bHi) => + (Math.Max(aLo, bLo) + Math.Min(aHi, bHi)) / 2.0; + + /// Clamps into the inclusive range [, ]. + private static double Clamp(double value, double min, double max) => + Math.Clamp(value, min, max); +} diff --git a/src/DemaConsulting.Rendering.Layout/ContainmentLayout.cs b/src/DemaConsulting.Rendering.Layout/ContainmentLayout.cs new file mode 100644 index 0000000..c19886d --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/ContainmentLayout.cs @@ -0,0 +1,157 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout; + +/// +/// Options controlling how packs child boxes into a container region. +/// +/// +/// Maximum width of the content area (excluding outer padding), in logical pixels. Rows wrap to a new +/// line once the next child would push the current row past this budget. Must be positive. Mirrors the +/// wrap width a container gives its contents, analogous to ELK's node-size / content-area constraint. +/// +/// +/// Gap, in logical pixels, kept between adjacent children in the same row. Defaults to 8.0. +/// Corresponds to ELK's spacing.nodeNode along the row axis. +/// +/// +/// Gap, in logical pixels, kept between successive rows. Defaults to 8.0. Corresponds to ELK's +/// spacing.nodeNode across rows. +/// +/// +/// 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. +/// +/// +/// 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 +/// adapter matching its historical output). +/// +public sealed record ContainmentOptions( + double MaxContentWidth, + double HorizontalGap = 8.0, + double VerticalGap = 8.0, + double Padding = 12.0); + +/// +/// The result of a containment-packing operation: the packed region size together with the input boxes +/// repositioned to their packed coordinates. +/// +/// Total width of the packed region (including outer padding) in logical pixels. +/// Total height of the packed region (including outer padding) in logical pixels. +/// +/// The input boxes, in their original order, each repositioned to its packed X/Y. Every +/// coordinate is relative to the region origin (0, 0); all non-position fields (label, depth, +/// shape, compartments, children, keyword) are carried through unchanged. +/// +public sealed record ContainmentResult( + double Width, + double Height, + IReadOnlyList Children); + +/// +/// Packs a set of already-sized model boxes into a single container region, arranging them into rows +/// within a width budget. This is the public, model-speaking containment building block: it maps each +/// child's size onto the internal row bin-packer, then returns the same boxes repositioned to their +/// packed coordinates plus the size of the region that encloses them. +/// +/// +/// +/// The operation is deterministic and preserves input order: given the same children and options it +/// always produces the same geometry, and the returned children appear in the same order they were +/// supplied. Children are laid out left to right along each row and wrap to a new row beneath the current +/// one when the next child would exceed . A child wider +/// than the content width is placed alone on its own row, and the region widens to contain it. +/// +/// +/// No two packed boxes overlap, and every packed box lies fully within the returned +/// by region. That region +/// includes the uniform on every side, so an empty input yields +/// a padding-only region. All coordinates are relative to the region origin (0, 0); a caller that +/// nests the region inside a parent box offsets the children by the region's placement. +/// +/// +/// The operation is deliberately model-agnostic: only each child's and +/// influence placement, and every other field is carried onto the +/// repositioned box unchanged. It composes with, rather than replaces, the layered algorithm — use it to +/// arrange peers inside a container when their relative order, not their connectivity, drives the layout. +/// +/// +/// +/// +/// // Three already-sized boxes to arrange inside a container. +/// var boxes = new[] +/// { +/// new LayoutBox(0, 0, 80, 40, "A", 0, BoxShape.Rectangle, [], []), +/// new LayoutBox(0, 0, 120, 40, "B", 0, BoxShape.Rectangle, [], []), +/// new LayoutBox(0, 0, 60, 40, "C", 0, BoxShape.Rectangle, [], []), +/// }; +/// +/// // Pack them into rows no wider than 200px of content, with the default gaps and padding. +/// var result = ContainmentLayout.Pack(boxes, new ContainmentOptions(MaxContentWidth: 200)); +/// +/// // result.Children are the same boxes repositioned to their packed X/Y (region-relative); +/// // result.Width x result.Height is the container size that fits them plus padding. +/// var container = new LayoutBox(0, 0, result.Width, result.Height, "Group", 0, BoxShape.Folder, [], result.Children); +/// +/// +public static class ContainmentLayout +{ + /// + /// Packs the given into a container region according to + /// . + /// + /// + /// The boxes to pack, in the desired visual order. Only each box's width and height affect placement; + /// all other fields are preserved on the repositioned box. + /// + /// + /// Packing options: the row-wrap content width plus the horizontal gap, vertical gap, and outer + /// padding. + /// + /// + /// A carrying the region size and the input boxes repositioned to + /// their packed, region-relative coordinates, in input order. + /// + /// + /// Thrown when , , or any element of + /// is . + /// + public static ContainmentResult Pack( + IReadOnlyList children, + ContainmentOptions options) + { + ArgumentNullException.ThrowIfNull(children); + ArgumentNullException.ThrowIfNull(options); + + // Map each child onto a size-only pack item, matching the packer back to the child by index. + var items = new PackItem[children.Count]; + for (var i = 0; i < children.Count; i++) + { + var child = children[i]; + ArgumentNullException.ThrowIfNull(child); + items[i] = new PackItem(child.Width, child.Height); + } + + var packed = ContainmentPacker.Pack( + items, + options.MaxContentWidth, + options.HorizontalGap, + options.VerticalGap, + options.Padding); + + // Reposition each child to its packed rectangle, preserving every non-position field. + var placed = new LayoutBox[children.Count]; + for (var i = 0; i < children.Count; i++) + { + var rect = packed.Rects[i]; + placed[i] = children[i] with { X = rect.X, Y = rect.Y }; + } + + return new ContainmentResult(packed.Width, packed.Height, placed); + } +} diff --git a/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs new file mode 100644 index 0000000..3374d01 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs @@ -0,0 +1,179 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Layout; + +/// +/// The bundled containment layout algorithm: arranges the input graph's top-level nodes by packing them +/// into rows within a width budget, then routes each edge around the packed boxes with the selected +/// style. This is a second reference +/// implementation alongside the layered algorithm; it composes the reusable +/// packer and the orchestration. +/// +/// +/// +/// Where the bundled arranges nodes by their connectivity into +/// Sugiyama layers, the containment algorithm arranges them by their reading order: each +/// node becomes a leaf box, the boxes are packed left to right and wrapped into rows within a +/// heuristic content width, and the graph's edges are then routed around the packed boxes. It suits +/// views whose elements group as peers inside a container rather than flowing along a directed +/// spine. +/// +/// +/// The algorithm is deterministic and order-preserving: given the same graph and options it always +/// produces the same geometry, and the placed boxes appear in the same order as +/// . It reads only the top-level nodes and edges — a node's nested +/// are treated as opaque and are not laid out at this level. +/// The content-width budget is derived from a roughly four-by-three canvas heuristic (the square root +/// of the total box area, widened to at least the widest box) so a wide set of boxes wraps into a +/// balanced block rather than one long row. +/// +/// +/// Edges whose or is not a +/// top-level node of the graph are skipped, mirroring how the layered algorithm drops out-of-graph +/// endpoints. Every routed connector carries its edge's , +/// , and . +/// +/// +/// +/// +/// // Build a small graph of peer boxes joined by a couple of edges. +/// 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"; +/// var e1 = graph.AddEdge("e1", a, b); +/// e1.TargetEnd = EndMarkerStyle.FilledArrow; +/// graph.AddEdge("e2", a, c); +/// +/// // Pack the nodes into rows and route the edges around them. +/// var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions()); +/// +/// // Hand the placed tree to a renderer (for example the SVG renderer). +/// // new SvgRenderer().Render(tree, new RenderOptions(Themes.Light), stream); +/// +/// +public sealed class ContainmentLayoutAlgorithm : ILayoutAlgorithm +{ + /// + /// The stable algorithm identifier "containment" under which this algorithm is selected and + /// registered. Pass it to or + /// instead of hardcoding the literal string. + /// + public const string AlgorithmId = "containment"; + + /// + /// Target width-to-height ratio of the packed content block. A value of 4/3 biases the + /// derived content width toward a landscape canvas so a wide set of boxes wraps into a balanced + /// block rather than a single long row. + /// + private const double CanvasAspectRatio = 4.0 / 3.0; + + /// + /// Lower bound applied to the derived content width so the packer always receives a positive width, + /// even for a graph with no nodes. + /// + private const double MinContentWidth = 1.0; + + /// + /// Gap, in logical pixels, kept between packed boxes on both axes. It is sized to leave room for a + /// connector to pass cleanly between two packed boxes: the orthogonal router steps off a box edge by + /// an approach stub of roughly the routing clearance plus a small margin, so a gap wider than that + /// stub lets an edge route around an intervening box instead of being forced to cross it. + /// + private const double NodeSpacing = 24.0; + + /// + public string Id => AlgorithmId; + + /// + public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(options); + + var graphNodes = graph.Nodes; + var count = graphNodes.Count; + + // Convert each top-level node into a leaf box, remembering its index so edges can be mapped to + // the packed box that ends up representing the node. Nested children are not laid out here. + var boxes = new LayoutBox[count]; + var indexOf = new Dictionary(count); + for (var i = 0; i < count; i++) + { + var node = graphNodes[i]; + indexOf[node] = i; + boxes[i] = new LayoutBox( + 0, + 0, + node.Width, + node.Height, + node.Label, + Depth: 0, + BoxShape.Rectangle, + Compartments: [], + Children: []); + } + + // 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. + var containmentOptions = new ContainmentOptions( + ComputeContentWidth(boxes), + HorizontalGap: NodeSpacing, + VerticalGap: NodeSpacing); + var result = ContainmentLayout.Pack(boxes, containmentOptions); + var packedBoxes = result.Children; + + // Build one connection per edge whose endpoints are both top-level nodes, carrying the edge's + // styling; edges referencing out-of-graph nodes are skipped. + var connections = new List(graph.Edges.Count); + foreach (var edge in graph.Edges) + { + if (indexOf.TryGetValue(edge.Source, out var s) && + indexOf.TryGetValue(edge.Target, out var t)) + { + connections.Add(new Connection( + packedBoxes[s], + packedBoxes[t], + edge.TargetEnd, + edge.LineStyle, + edge.Label)); + } + } + + // Route the connections around the packed boxes using the per-scope routing style. + var routeOptions = new ConnectorRouteOptions(options.Get(CoreOptions.EdgeRouting)); + var routedLines = ConnectorRouter.Route(packedBoxes, connections, routeOptions); + + var nodes = new List(packedBoxes.Count + routedLines.Count); + nodes.AddRange(packedBoxes); + nodes.AddRange(routedLines); + + return new LayoutTree(result.Width, result.Height, nodes); + } + + /// + /// 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. + /// + private static double ComputeContentWidth(IReadOnlyList boxes) + { + var totalArea = 0.0; + var widest = 0.0; + foreach (var box in boxes) + { + totalArea += box.Width * box.Height; + widest = Math.Max(widest, box.Width); + } + + var target = Math.Sqrt(totalArea * CanvasAspectRatio); + return Math.Max(Math.Max(widest, target), MinContentWidth); + } +} diff --git a/src/DemaConsulting.Rendering.Layout/DemaConsulting.Rendering.Layout.csproj b/src/DemaConsulting.Rendering.Layout/DemaConsulting.Rendering.Layout.csproj new file mode 100644 index 0000000..83fd736 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/DemaConsulting.Rendering.Layout.csproj @@ -0,0 +1,85 @@ + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + + DemaConsulting.Rendering.Layout + 0.0.0 + DEMA Consulting + DEMA Consulting + Pluggable diagram layout algorithms (ELK-inspired layered pipeline, packers, and routers) for the DEMA Consulting rendering library + MIT + https://github.com/demaconsulting/Rendering + https://github.com/demaconsulting/Rendering + README.md + Icon.png + rendering;layout;elk;dotnet-library + Copyright DEMA Consulting + Rendering Layout + Rendering + + + true + snupkg + true + true + true + + + true + true + true + true + true + latest + + + true + + + true + $(PackageId) + $(Version) + Organization: $(Company) + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/DemaConsulting.Rendering.Layout/Engine/ContainmentPacker.cs b/src/DemaConsulting.Rendering.Layout/Engine/ContainmentPacker.cs new file mode 100644 index 0000000..dd2eaff --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/ContainmentPacker.cs @@ -0,0 +1,116 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine; + +/// +/// A single item to be packed, identified only by its size. Callers map the packed +/// rectangles back to their model elements by index. +/// +/// Required width of the item in logical pixels. +/// Required height of the item in logical pixels. +internal readonly record struct PackItem(double Width, double Height); + +/// +/// A packed rectangle: the position assigned to the item at the same index in the input list. +/// +/// Absolute X coordinate of the left edge in logical pixels. +/// Absolute Y coordinate of the top edge in logical pixels. +/// Width of the item in logical pixels. +/// Height of the item in logical pixels. +internal readonly record struct PackedRect(double X, double Y, double Width, double Height); + +/// +/// The result of a packing operation. +/// +/// Total width of the packed region (including outer padding) in logical pixels. +/// Total height of the packed region (including outer padding) in logical pixels. +/// +/// Packed rectangles, one per input item in the same order. Each rectangle is positioned +/// relative to the region origin (0, 0). +/// +internal sealed record PackResult(double Width, double Height, IReadOnlyList Rects); + +/// +/// A shelf (row) bin-packing engine. Places a sequence of variable-size items left to right, +/// wrapping to a new row when the next item would exceed the maximum content width, and sizes +/// the enclosing region to fit all items plus uniform outer padding. +/// +/// +/// The algorithm is deterministic and preserves input order. It guarantees that no two packed +/// rectangles overlap and that every rectangle lies within the returned region bounds. An item +/// wider than the available content width is placed alone on its own row at the content width's +/// left edge (it may extend the region width). +/// +internal static class ContainmentPacker +{ + /// + /// Packs the given items into rows within . + /// + /// Items to pack, in the desired visual order. + /// + /// Maximum width of the content area (excluding outer padding). Rows wrap when exceeded. + /// Must be positive. + /// + /// Gap between adjacent items in the same row. + /// Gap between adjacent rows. + /// Uniform padding added around the entire packed region. + /// A describing item positions and the region size. + public static PackResult Pack( + IReadOnlyList items, + double maxContentWidth, + double horizontalGap, + double verticalGap, + double padding) + { + ArgumentNullException.ThrowIfNull(items); + + // Empty input yields a zero-content region consisting only of padding on both axes. + if (items.Count == 0) + { + return new PackResult(2.0 * padding, 2.0 * padding, []); + } + + var rects = new PackedRect[items.Count]; + + var cursorX = padding; + var rowTopY = padding; + var rowHeight = 0.0; + var widestContentRight = padding; + var isFirstInRow = true; + + for (var i = 0; i < items.Count; i++) + { + 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. + var prospectiveRight = cursorX + item.Width; + var contentRightLimit = padding + maxContentWidth; + if (!isFirstInRow && prospectiveRight > contentRightLimit) + { + // Wrap to a new row below the tallest item of the current row. The wrapped item + // is placed immediately below as the sole occupant of the new row; isFirstInRow is + // reset to false at the end of this iteration once the item has been placed. + rowTopY += rowHeight + verticalGap; + cursorX = padding; + rowHeight = 0.0; + } + + rects[i] = new PackedRect(cursorX, rowTopY, item.Width, item.Height); + + // Advance the horizontal cursor past this item plus a trailing gap. + cursorX += item.Width + horizontalGap; + rowHeight = Math.Max(rowHeight, item.Height); + widestContentRight = Math.Max(widestContentRight, rects[i].X + item.Width); + isFirstInRow = false; + } + + // Total size: widest row's right edge + padding; last row's bottom + padding. + var totalWidth = widestContentRight + padding; + var totalHeight = rowTopY + rowHeight + padding; + + return new PackResult(totalWidth, totalHeight, rects); + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs b/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs new file mode 100644 index 0000000..07e16d9 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs @@ -0,0 +1,120 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine.Layered; + +using static DemaConsulting.Rendering.Layout.Engine.Layered.LayeredLayoutMetrics; + +namespace DemaConsulting.Rendering.Layout.Engine; + +/// +/// A node to be placed by the , identified by its size. +/// +/// Width of the node's bounding box in logical pixels. +/// Height of the node's bounding box in logical pixels. +internal readonly record struct LayerNode(double Width, double Height); + +/// +/// A directed edge (from a source node to a target node, by index) used for layering. +/// +/// Index of the source node. +/// Index of the target node. +internal readonly record struct LayerEdge(int Source, int Target); + +/// +/// The result of an interconnection layout pass. +/// +/// Placed rectangles, one per input node in the same order. +/// Total diagram width in logical pixels, including padding. +/// Total diagram height in logical pixels, including padding. +/// Assigned Sugiyama layer index for each node, in node order. +/// Orthogonal connector waypoints for each acyclic edge. +internal sealed record LayerResult( + IReadOnlyList Rects, + double TotalWidth, + double TotalHeight, + IReadOnlyList NodeLayers, + IReadOnlyList> ConnectorWaypoints) +{ + /// + /// Gets the acyclic edge set (by node index), index-aligned with . + /// + /// + /// The layered pipeline's cycle-breaking stage drops self-loops, de-duplicates identical directed + /// pairs, and reverses back edges, so holds one polyline per + /// acyclic edge rather than one per input edge. Consumers key a + /// (source, target) → polyline lookup on this list (reversing the polyline for a reversed + /// back edge) to recover the route for each of their own input edges. + /// + public IReadOnlyList AcyclicEdges { get; init; } = []; +} + +/// +/// 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. +/// +/// +/// 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). +/// +internal static class InterconnectionLayoutEngine +{ + /// + /// Computes a full Sugiyama layered placement and ELK-style slot routing for the given nodes + /// and directed edges, returning box positions and orthogonal connector waypoints. + /// + /// Input nodes to place, in caller order. + /// Directed edges between nodes (by index). + /// Placement result with rects, layer assignments, and connector waypoints. + public static LayerResult Place( + IReadOnlyList nodes, + IReadOnlyList edges) + { + ArgumentNullException.ThrowIfNull(nodes); + ArgumentNullException.ThrowIfNull(edges); + + var n = nodes.Count; + if (n == 0) + { + return new LayerResult([], 2.0 * Padding, 2.0 * Padding, [], []); + } + + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + var pipeline = LayeredLayoutPipeline.Builder() + .Direction(LayoutDirection.Right) + .Hierarchy(Layered.HierarchyHandling.Flat) + .AddDefaultStages() + .Build(); + pipeline.Run(graph); + + var augX = graph.AugX; + var augY = graph.AugY; + var columnX = graph.ColumnX; + var maxColWidth = graph.MaxColWidth; + + // Assemble result. + var rects = new Rect[n]; + for (var i = 0; i < n; i++) + { + rects[i] = new Rect(augX[i], augY[i], nodes[i].Width, nodes[i].Height); + } + + var lastLayer = columnX.Length - 1; + var totalWidth = columnX[lastLayer] + maxColWidth[lastLayer] + Padding; + var totalHeight = Padding; + for (var i = 0; i < n; i++) + { + totalHeight = Math.Max(totalHeight, augY[i] + nodes[i].Height + Padding); + } + + return new LayerResult(rects, totalWidth, totalHeight, graph.NodeLayers, graph.Waypoints) + { + AcyclicEdges = graph.Acyclic, + }; + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/AxisTransform.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/AxisTransform.cs new file mode 100644 index 0000000..f10c5fe --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/AxisTransform.cs @@ -0,0 +1,186 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Pipeline stage that maps the abstract along/cross coordinates computed by the earlier stages +/// onto screen coordinates for the requested . +/// +/// +/// +/// The direction-agnostic stages always compute in the -equivalent +/// abstract axes: along = +X (layer progression) and cross = +Y (within-layer order). A +/// node's along-extent is its and its cross-extent its +/// . This stage is the single place that converts those abstract +/// coordinates into screen coordinates, isolating all direction handling to one unit. +/// +/// +/// Mapping a layout top-to-bottom or bottom-to-top additionally requires the along-axis to be the node +/// height rather than its width. A purely-final coordinate remap is therefore insufficient +/// for /: the input node sizes must +/// first be normalized (width↔height swapped) so the stages space layers by height. That +/// normalization is performed by , which the pipeline calls before the +/// stage loop. is a literal no-op here (output byte-identical) and +/// only reflects the X axis without swapping. +/// +/// +internal sealed class AxisTransform : ILayoutStage +{ + /// + /// Normalizes the input node axes for the requested direction before the stages run. + /// + /// The graph whose node sizes may be swapped in place. + /// + /// For / the node width and + /// 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. + /// + public static void NormalizeInputAxes(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + + if (graph.Direction is LayoutDirection.Down or LayoutDirection.Up) + { + graph.SwapNodeAxes(); + } + } + + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + + if (graph.Direction == LayoutDirection.Right) + { + // RIGHT identity: coordinates are already in screen space; nothing to transform. + return; + } + + // LEFT/UP reflect the along-axis about its maximum; DOWN is a pure transpose (max unused). + var maxAlong = ComputeMaxAlong(graph); + + RemapNodes(graph, maxAlong); + RemapWaypoints(graph, maxAlong); + } + + /// + /// Maps each augmented-node abstract top-left onto its screen top-left for the direction. + /// + /// The graph whose node coordinates are replaced. + /// The maximum along (abstract +X) coordinate, used by LEFT/UP reflection. + private static void RemapNodes(LayeredGraph graph, double maxAlong) + { + var augX = graph.AugX; + var augY = graph.AugY; + var augNodes = graph.AugNodes; + + var newX = new double[augX.Length]; + var newY = new double[augY.Length]; + for (var i = 0; i < augX.Length; i++) + { + var alongExtent = i < augNodes.Count ? augNodes[i].Width : 0.0; + (newX[i], newY[i]) = MapNodeTopLeft(graph.Direction, augX[i], augY[i], alongExtent, maxAlong); + } + + graph.AugX = newX; + graph.AugY = newY; + } + + /// Maps every waypoint of every original edge onto screen space for the direction. + /// The graph whose waypoints are replaced. + /// The maximum along (abstract +X) coordinate, used by LEFT/UP reflection. + private static void RemapWaypoints(LayeredGraph graph, double maxAlong) + { + var waypoints = graph.Waypoints; + var mapped = new IReadOnlyList[waypoints.Count]; + for (var e = 0; e < waypoints.Count; e++) + { + var polyline = waypoints[e]; + var points = new Point2D[polyline.Count]; + for (var p = 0; p < polyline.Count; p++) + { + points[p] = MapPoint(graph.Direction, polyline[p], maxAlong); + } + + mapped[e] = points; + } + + graph.Waypoints = mapped; + } + + /// Maps a single abstract point onto screen space for the given direction. + /// The requested layout flow direction. + /// The abstract-space point (along = X, cross = Y). + /// The maximum along coordinate, used by LEFT/UP reflection. + /// The screen-space point. + private static Point2D MapPoint(LayoutDirection direction, Point2D point, double maxAlong) + { + // S2234: the DOWN/UP transpose deliberately feeds the cross coordinate as screen X and the + // along coordinate as screen Y; the "reversed" argument order is the intended axis swap. +#pragma warning disable S2234 + return direction switch + { + // DOWN: pure transpose (along -> screen Y, cross -> screen X). + LayoutDirection.Down => new Point2D(point.Y, point.X), + + // LEFT: reflect the along-axis; cross is unchanged. + LayoutDirection.Left => new Point2D(maxAlong - point.X, point.Y), + + // UP: transpose with the along-axis reflected into screen Y. + LayoutDirection.Up => new Point2D(point.Y, maxAlong - point.X), + + _ => point, + }; +#pragma warning restore S2234 + } + + /// Maps an abstract node top-left onto its screen top-left for the given direction. + /// The requested layout flow direction. + /// The node's abstract along (X) coordinate. + /// The node's abstract cross (Y) coordinate. + /// The node's along-extent (its abstract width). + /// The maximum along coordinate, used by LEFT/UP reflection. + /// The node's screen top-left (drawn there with its intrinsic width and height). + private static (double X, double Y) MapNodeTopLeft( + LayoutDirection direction, + double alongTopLeft, + double crossTopLeft, + double alongExtent, + double maxAlong) => direction switch + { + LayoutDirection.Down => (crossTopLeft, alongTopLeft), + LayoutDirection.Left => (maxAlong - (alongTopLeft + alongExtent), crossTopLeft), + LayoutDirection.Up => (crossTopLeft, maxAlong - (alongTopLeft + alongExtent)), + _ => (alongTopLeft, crossTopLeft), + }; + + /// + /// Returns the maximum along (abstract +X) coordinate over all augmented-node far corners and all + /// waypoint X values, which LEFT/UP reflect about. + /// + /// The placed and routed graph. + /// The maximum along coordinate (0 when the graph is empty). + private static double ComputeMaxAlong(LayeredGraph graph) + { + var max = 0.0; + var augX = graph.AugX; + var augNodes = graph.AugNodes; + for (var i = 0; i < augX.Length && i < augNodes.Count; i++) + { + max = Math.Max(max, augX[i] + augNodes[i].Width); + } + + foreach (var polyline in graph.Waypoints) + { + foreach (var point in polyline) + { + max = Math.Max(max, point.X); + } + } + + return max; + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BrandesKopfPlacer.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BrandesKopfPlacer.cs new file mode 100644 index 0000000..d34dac3 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BrandesKopfPlacer.cs @@ -0,0 +1,806 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// +using static DemaConsulting.Rendering.Layout.Engine.Layered.LayeredLayoutMetrics; + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Pipeline stage that assigns absolute X and Y coordinates to all augmented nodes using ELK's +/// horizontal placement and the Brandes-Kopf balanced four-layout Y placement. +/// +internal sealed class BrandesKopfPlacer : ILayoutStage +{ + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + var (augX, augY, columnX, maxColWidth) = AssignCoordinatesAug(graph.AugNodes, graph.Groups, graph.AugEdges); + graph.AugX = augX; + graph.AugY = augY; + graph.ColumnX = columnX; + graph.MaxColWidth = maxColWidth; + } + + /// + /// Assigns absolute X and Y coordinates to all augmented nodes. + /// + /// + /// + /// Y assignment follows the Brandes-Köpf (BK) four-layout balanced algorithm + /// (BKNodePlacer + BKAligner + BKCompactor): four independent + /// vertical alignments (DOWN/UP × RIGHT/LEFT) are compacted and their per-node medians + /// averaged to produce port-aligned, crossing-minimized vertical positions. + /// + /// + /// X assignment follows ELK's LGraphUtil.placeNodesHorizontally: dummies are placed at + /// the horizontal center of their column; real nodes are left-aligned to their column start. + /// Corridor widths are derived from sub-edge counts per corridor. + /// + /// + private static (double[] AugX, double[] AugY, double[] ColumnX, double[] MaxColWidth) AssignCoordinatesAug( + List augNodes, + List> groups, + List augEdges) + { + var layerCount = groups.Count; + var numAug = augNodes.Count; + + // Maximum real-node width per layer (dummies have width 0). + var maxColWidth = new double[layerCount]; + for (var i = 0; i < numAug; i++) + { + if (!augNodes[i].IsDummy) + { + maxColWidth[augNodes[i].Layer] = Math.Max(maxColWidth[augNodes[i].Layer], augNodes[i].Width); + } + } + + // Sub-edges per corridor: one sub-edge per augEdge, keyed on source layer. + var corridorEdgeCounts = new int[Math.Max(1, layerCount - 1)]; + foreach (var ae in augEdges) + { + var l = augNodes[ae.Source].Layer; + if (l >= 0 && l < corridorEdgeCounts.Length) + { + corridorEdgeCounts[l]++; + } + } + + // Column X positions. Corridor width: ELK routingWidth = 2*edgeNodeSpacing + (n-1)*edgeEdgeSpacing. + var columnX = new double[layerCount]; + columnX[0] = Padding; + for (var l = 1; l < layerCount; l++) + { + var cnt = corridorEdgeCounts[l - 1]; + var corridorWidth = cnt > 0 + ? Math.Max(CorridorMinWidth, (2.0 * ConnectorClearance) + ((cnt - 1) * EdgeSpacing)) + : CorridorMinWidth; + columnX[l] = columnX[l - 1] + maxColWidth[l - 1] + corridorWidth; + } + + // Assign X coordinates: dummies are centered in their column; real nodes left-align. + var augX = new double[numAug]; + for (var l = 0; l < layerCount; l++) + { + var colCenterX = columnX[l] + (maxColWidth[l] / 2.0); + foreach (var ni in groups[l]) + { + augX[ni] = augNodes[ni].IsDummy ? colCenterX : columnX[l]; + } + } + + // Assign Y coordinates using the Brandes-Köpf balanced four-layout algorithm. + var augY = BkAssignYCoordinates(augNodes, groups, augEdges); + + return (augX, augY, columnX, maxColWidth); + } + + /// + /// Assigns Y coordinates to all augmented nodes using the four-layout Brandes-Köpf + /// balanced algorithm, producing port-aligned vertical positions. + /// + /// + /// + /// Runs four independent (vDown × hRight) alignment-and-compaction pipelines, one for + /// each combination of vertical scan direction (DOWN = top-to-bottom, UP = bottom-to-top) + /// and horizontal scan direction (RIGHT = layer 0 → max, LEFT = layer max → 0). The + /// per-node average of the two middle values of the four results gives the final balanced + /// position. Padding is added once at the end. + /// + /// + /// Corresponds to ELK's BKNodePlacer orchestrating BKAligner and BKCompactor. + /// + /// + private static double[] BkAssignYCoordinates( + List augNodes, + List> groups, + List augEdges) + { + var numAug = augNodes.Count; + + // Step 0: precompute port positions, layer positions, and neighbor-edge lists. + BkPreprocess( + augNodes, groups, augEdges, + out var posInLayer, + out var srcRelPortY, + out var tgtRelPortY, + out var leftNeighborEdges, + out var rightNeighborEdges); + + // Step 1: mark type-1 conflicts (non-inner segments crossing inner segments). + var markedEdges = BkMarkConflicts(augNodes, groups, augEdges, posInLayer, leftNeighborEdges); + + // Steps 2–4: compute four independent (vDown × hRight) layouts. + var layouts = new double[4][]; + for (var d = 0; d < 4; d++) + { + // d=0: DOWN+RIGHT, d=1: UP+RIGHT, d=2: DOWN+LEFT, d=3: UP+LEFT. + var vDown = d % 2 == 0; + var hRight = d < 2; + + // Step 2: vertical alignment — builds block chains along the scan direction. + BkVerticalAlignment( + augNodes, groups, augEdges, posInLayer, + leftNeighborEdges, rightNeighborEdges, + markedEdges, vDown, hRight, + out var root, out var align); + + // Step 3: inside-block shift — adjusts nodes within each block to align ports. + var innerShift = BkInsideBlockShift( + augNodes, augEdges, root, align, + srcRelPortY, tgtRelPortY, hRight, + rightNeighborEdges, leftNeighborEdges); + + // Step 4: horizontal compaction — assigns absolute Y to each block root. + var blockY = BkHorizontalCompaction(augNodes, groups, root, align, innerShift, posInLayer, vDown); + + // Compute absolute Y for every node in this layout. + var y = new double[numAug]; + for (var i = 0; i < numAug; i++) + { + y[i] = blockY[root[i]] + innerShift[i]; + } + + layouts[d] = y; + } + + // Step 5: normalize each layout and return the balanced (median average) result. + return BkBalancedLayout(layouts, numAug); + } + + /// + /// Precomputes the lookup tables required by all four Brandes-Köpf layout passes: + /// per-node layer position, relative port Y offsets, and sorted neighbor-edge lists. + /// + /// + /// + /// Port positions follow ELK's BKAligner preprocessing convention: a dummy node + /// contributes relative port Y = 0 (the wire passes straight through); a real node + /// distributes its ports evenly between insets, or + /// at the midpoint when it has only one port on that face. + /// + /// + /// [v] lists augmented-edge indices whose target is v, + /// sorted ascending by the source node's position within its layer — used for RIGHT-direction + /// alignment. [v] lists augmented-edge indices whose + /// source is v, sorted ascending by the target node's position — used for LEFT-direction + /// alignment. Both lists are stable-sorted by edge index as a tiebreaker. + /// + /// + private static void BkPreprocess( + List augNodes, + List> groups, + List augEdges, + out int[] posInLayer, + out double[] srcRelPortY, + out double[] tgtRelPortY, + out List[] leftNeighborEdges, + out List[] rightNeighborEdges) + { + var numAug = augNodes.Count; + var numEdges = augEdges.Count; + + // Position of each node within its layer group (0-based index in groups[layer]). + posInLayer = new int[numAug]; + for (var l = 0; l < groups.Count; l++) + { + for (var k = 0; k < groups[l].Count; k++) + { + posInLayer[groups[l][k]] = k; + } + } + + // Collect outgoing/incoming edge indices per node for port computation. + // Capture posInLayer in a local so it can be used inside lambda expressions + // (C# prohibits capturing out parameters directly in lambdas — CS1628). + var posLayer = posInLayer; + var outEdges = new List[numAug]; + var inEdges = new List[numAug]; + for (var i = 0; i < numAug; i++) + { + outEdges[i] = []; + inEdges[i] = []; + } + + for (var ei = 0; ei < numEdges; ei++) + { + outEdges[augEdges[ei].Source].Add(ei); + inEdges[augEdges[ei].Target].Add(ei); + } + + // srcRelPortY[e]: Y of the source (EAST) port relative to source node's top-left. + // Dummy nodes pass the wire through at Y = 0 relative to their own position. + srcRelPortY = new double[numEdges]; + for (var ni = 0; ni < numAug; ni++) + { + var edges = outEdges[ni]; + if (edges.Count == 0) + { + continue; + } + + if (augNodes[ni].IsDummy) + { + foreach (var ei in edges) + { + srcRelPortY[ei] = 0.0; + } + } + else + { + // Sort by target's position in its layer, then edge index for stability. + var sorted = edges + .OrderBy(ei => posLayer[augEdges[ei].Target]) + .ThenBy(ei => ei) + .ToList(); + var portCount = sorted.Count; + for (var k = 0; k < portCount; k++) + { + srcRelPortY[sorted[k]] = portCount == 1 + ? augNodes[ni].Height / 2.0 + : ConnectorClearance + (k * (augNodes[ni].Height - (2.0 * ConnectorClearance)) / (portCount - 1)); + } + } + } + + // tgtRelPortY[e]: Y of the target (WEST) port relative to target node's top-left. + tgtRelPortY = new double[numEdges]; + for (var ni = 0; ni < numAug; ni++) + { + var edges = inEdges[ni]; + if (edges.Count == 0) + { + continue; + } + + if (augNodes[ni].IsDummy) + { + foreach (var ei in edges) + { + tgtRelPortY[ei] = 0.0; + } + } + else + { + // Sort by source's position in its layer, then edge index for stability. + var sorted = edges + .OrderBy(ei => posLayer[augEdges[ei].Source]) + .ThenBy(ei => ei) + .ToList(); + var portCount = sorted.Count; + for (var k = 0; k < portCount; k++) + { + tgtRelPortY[sorted[k]] = portCount == 1 + ? augNodes[ni].Height / 2.0 + : ConnectorClearance + (k * (augNodes[ni].Height - (2.0 * ConnectorClearance)) / (portCount - 1)); + } + } + } + + // leftNeighborEdges[v]: edges whose Target == v, sorted by source posInLayer. + leftNeighborEdges = new List[numAug]; + for (var i = 0; i < numAug; i++) + { + leftNeighborEdges[i] = []; + } + + for (var ei = 0; ei < numEdges; ei++) + { + leftNeighborEdges[augEdges[ei].Target].Add(ei); + } + + for (var i = 0; i < numAug; i++) + { + leftNeighborEdges[i].Sort((a, b) => + { + var c = posLayer[augEdges[a].Source].CompareTo(posLayer[augEdges[b].Source]); + return c != 0 ? c : a.CompareTo(b); + }); + } + + // rightNeighborEdges[v]: edges whose Source == v, sorted by target posInLayer. + rightNeighborEdges = new List[numAug]; + for (var i = 0; i < numAug; i++) + { + rightNeighborEdges[i] = []; + } + + for (var ei = 0; ei < numEdges; ei++) + { + rightNeighborEdges[augEdges[ei].Source].Add(ei); + } + + for (var i = 0; i < numAug; i++) + { + rightNeighborEdges[i].Sort((a, b) => + { + var c = posLayer[augEdges[a].Target].CompareTo(posLayer[augEdges[b].Target]); + return c != 0 ? c : a.CompareTo(b); + }); + } + } + + /// + /// Returns true when is incident to an inner segment — a sub-edge + /// where both endpoints are dummy nodes, forming part of a long-edge chain. + /// + /// + /// An inner segment has both endpoints as dummy nodes. The check inspects v's + /// lowest-positioned left neighbor (leftNeighborEdges[v][0]). Used by type-1 conflict + /// detection to identify nodes that anchor inner segments across layer boundaries. + /// + private static bool IsIncidentToInnerSegment( + int v, + List augNodes, + List augEdges, + List[] leftNeighborEdges) + => augNodes[v].IsDummy + && leftNeighborEdges[v].Count > 0 + && augNodes[augEdges[leftNeighborEdges[v][0]].Source].IsDummy; + + /// + /// Marks augmented edges that participate in type-1 conflicts: a non-inner segment + /// (at least one real-node endpoint) that crosses an inner segment (both endpoints + /// are dummy nodes from a long-edge chain). + /// + /// + /// Implements ELK's markConflicts procedure from BKNodePlacer. For each pair of + /// adjacent middle layers (i, i+1), the algorithm tracks the permitted source-position + /// range [k0, k1] established by each inner segment and marks any non-inner segment + /// whose source falls outside that range. Marked edges are excluded from vertical + /// alignment to preserve the topology of inner segments. + /// + private static HashSet BkMarkConflicts( + List augNodes, + List> groups, + List augEdges, + int[] posInLayer, + List[] leftNeighborEdges) + { + var markedEdges = new HashSet(); + var maxLayer = groups.Count - 1; + + // Examine middle layers: i is the source side, i+1 is the target side. + for (var i = 1; i < maxLayer; i++) + { + var leftLayerSize = groups[i].Count; + var rightLayer = groups[i + 1]; + + // k0: lower bound of the permitted source-position range for the current batch. + var k0 = 0; + + // l: left cursor into the right layer (start of the current batch). + var l = 0; + + for (var l1 = 0; l1 < rightLayer.Count; l1++) + { + var v = rightLayer[l1]; + var incident = IsIncidentToInnerSegment(v, augNodes, augEdges, leftNeighborEdges); + + // Flush the batch at the last node or at each inner-segment anchor. + if (l1 != rightLayer.Count - 1 && !incident) + { + continue; + } + + // k1: upper bound of the permitted source-position range for this batch. + var k1 = leftLayerSize - 1; + if (incident) + { + var innerEdge = leftNeighborEdges[v][0]; + k1 = posInLayer[augEdges[innerEdge].Source]; + } + + // Mark non-inner segments in the batch whose sources are out of range. + while (l <= l1) + { + var vl = rightLayer[l]; + if (!IsIncidentToInnerSegment(vl, augNodes, augEdges, leftNeighborEdges)) + { + foreach (var edgeIdx in leftNeighborEdges[vl]) + { + var k = posInLayer[augEdges[edgeIdx].Source]; + if (k < k0 || k > k1) + { + markedEdges.Add(edgeIdx); + } + } + } + + l++; + } + + k0 = k1; + } + } + + return markedEdges; + } + + /// + /// Performs vertical alignment for one Brandes-Köpf layout direction, building the + /// circular block-chain structure that groups co-aligned nodes into blocks. + /// + /// + /// + /// Implements ELK's BKAligner.verticalAlignment. Each node v is aligned with the + /// median neighbor in the previous (RIGHT) or next (LEFT) layer that is still + /// unaligned (align[v] == v) and whose layer-position satisfies the monotone + /// constraint r. The monotone constraint prevents crossings between aligned pairs. + /// + /// + /// On output, [i] identifies the block root for every node i, + /// and [i] is the next node in the circular chain + /// (root → n1 → n2 → … → root). + /// + /// + private static void BkVerticalAlignment( + List augNodes, + List> groups, + List augEdges, + int[] posInLayer, + List[] leftNeighborEdges, + List[] rightNeighborEdges, + HashSet markedEdges, + bool vDown, + bool hRight, + out int[] root, + out int[] align) + { + var numAug = augNodes.Count; + var maxLayer = groups.Count - 1; + + // Every node starts as its own singleton block. + root = new int[numAug]; + align = new int[numAug]; + for (var i = 0; i < numAug; i++) + { + root[i] = i; + align[i] = i; + } + + // Layer iteration order: RIGHT scans forward (0..maxLayer); LEFT scans in reverse. + var layerStart = hRight ? 0 : maxLayer; + var layerEnd = hRight ? maxLayer : 0; + var layerStep = hRight ? 1 : -1; + + for (var l = layerStart; hRight ? l <= layerEnd : l >= layerEnd; l += layerStep) + { + var layer = groups[l]; + + // r: monotone position constraint; tracks the last aligned neighbor's position. + var r = vDown ? -1 : int.MaxValue; + + // Node iteration order: DOWN scans forward (0..N-1); UP scans in reverse. + var nodeStart = vDown ? 0 : layer.Count - 1; + var nodeEnd = vDown ? layer.Count - 1 : 0; + var nodeStep = vDown ? 1 : -1; + + for (var ni = nodeStart; vDown ? ni <= nodeEnd : ni >= nodeEnd; ni += nodeStep) + { + var v = layer[ni]; + var neighbors = hRight ? leftNeighborEdges[v] : rightNeighborEdges[v]; + var d = neighbors.Count; + if (d == 0) + { + continue; + } + + // Median index range for this node's neighbor list. + var low = (int)Math.Floor((d + 1) / 2.0) - 1; + var high = (int)Math.Ceiling((d + 1) / 2.0) - 1; + + // Try median neighbors in vdir order; stop as soon as v is aligned. + var mStart = vDown ? low : high; + var mEnd = vDown ? high : low; + var mStep = vDown ? 1 : -1; + + for (var m = mStart; vDown ? m <= mEnd : m >= mEnd; m += mStep) + { + // Stop iterating once v has been aligned with a neighbor. + if (align[v] != v) + { + break; + } + + var edgeIdx = neighbors[m]; + var uIdx = hRight ? augEdges[edgeIdx].Source : augEdges[edgeIdx].Target; + + if (markedEdges.Contains(edgeIdx)) + { + continue; + } + + var pos = posInLayer[uIdx]; + if (vDown ? r < pos : r > pos) + { + // Extend the block chain: insert v between uIdx and the current root. + align[uIdx] = v; + root[v] = root[uIdx]; + align[v] = root[v]; + r = pos; + } + } + } + } + } + + /// + /// Computes the inside-block shift for each node: the vertical offset relative to its + /// block root that makes the connecting ports co-linear within the block. + /// + /// + /// + /// Implements ELK's BKAligner.insideBlockShift. For each block root, walks the circular + /// align chain accumulating port-position differences between consecutive nodes. The + /// accumulated shifts are then normalized so the topmost node in the block has + /// innerShift = 0, making blockY the absolute Y of the block's highest point. + /// + /// + /// For hdir=RIGHT the edge between consecutive chain nodes goes source → target (earlier + /// layer to later layer), so portDiff = srcRelPortY − tgtRelPortY. For hdir=LEFT the + /// direction reverses, so portDiff = tgtRelPortY − srcRelPortY. + /// + /// + private static double[] BkInsideBlockShift( + List augNodes, + List augEdges, + int[] root, + int[] align, + double[] srcRelPortY, + double[] tgtRelPortY, + bool hRight, + List[] rightNeighborEdges, + List[] leftNeighborEdges) + { + var numAug = augNodes.Count; + var innerShift = new double[numAug]; + + // Process each block identified by its root node. + for (var r = 0; r < numAug; r++) + { + if (root[r] != r) + { + continue; + } + + // Walk the circular chain accumulating port-difference shifts. + var spaceAbove = 0.0; + var spaceBelow = augNodes[r].Height; + + var current = r; + var next = align[r]; + while (next != r) + { + // Locate the augmented edge that links consecutive block-chain nodes. + // For RIGHT: edge current → next (earlier → later layer). + // For LEFT: edge next → current (earlier → later layer, chain walks backward). + var edgeIdx = hRight + ? BkFindEdge(rightNeighborEdges[current], augEdges, next, findByTarget: true) + : BkFindEdge(leftNeighborEdges[current], augEdges, next, findByTarget: false); + + // Port alignment: accumulate the source-minus-target port offset. + var portDiff = hRight + ? srcRelPortY[edgeIdx] - tgtRelPortY[edgeIdx] + : tgtRelPortY[edgeIdx] - srcRelPortY[edgeIdx]; + + innerShift[next] = innerShift[current] + portDiff; + spaceAbove = Math.Max(spaceAbove, -innerShift[next]); + spaceBelow = Math.Max(spaceBelow, innerShift[next] + augNodes[next].Height); + + current = next; + next = align[current]; + } + + // Normalize: add spaceAbove to all shifts so the topmost node is at offset 0. + if (spaceAbove > 0.0) + { + var node = r; + do + { + innerShift[node] += spaceAbove; + node = align[node]; + } + while (node != r); + } + } + + return innerShift; + } + + /// + /// Finds the augmented edge in that connects to + /// , searching by target when + /// is true, or by source otherwise. + /// + /// + /// Used by to locate the edge that links consecutive + /// nodes in a block chain. Because block chains are built strictly along real augmented + /// edges, the edge is always present; returning −1 would indicate a logic error in the + /// vertical-alignment phase. + /// + private static int BkFindEdge( + List edgeList, + List augEdges, + int matchNode, + bool findByTarget) + { + foreach (var ei in edgeList) + { + if ((findByTarget ? augEdges[ei].Target : augEdges[ei].Source) == matchNode) + { + return ei; + } + } + + return -1; // Should not occur: block chains are always built along real edges. + } + + /// + /// Assigns an absolute Y coordinate (blockY) to each block root by compacting blocks + /// in the vertical direction, respecting per-node heights and gaps. + /// + /// + /// + /// Implements ELK's BKCompactor.horizontalCompaction / placeBlock. For vdir=DOWN, + /// blocks are packed top-to-bottom starting at Y = 0, constrained from above by the + /// node immediately above each chain member in its layer. For vdir=UP, blocks are + /// packed bottom-to-top starting at Y = 0, constrained from below (yielding negative + /// values that are normalized in ). + /// + /// + /// The local PlaceBlock function places a block root's Y coordinate by recursively + /// ensuring every constraining block is placed first (memoized via a NaN sentinel). + /// + /// + private static double[] BkHorizontalCompaction( + List augNodes, + List> groups, + int[] root, + int[] align, + double[] innerShift, + int[] posInLayer, + bool vDown) + { + var maxLayer = groups.Count - 1; + var blockY = new double[augNodes.Count]; + Array.Fill(blockY, double.NaN); + + // Recursively place a block root, memoized by the NaN-unplaced sentinel. + void PlaceBlock(int v) + { + if (!double.IsNaN(blockY[v])) + { + return; + } + + blockY[v] = 0.0; + + // Enforce separation constraints for every node in this block's chain. + var current = v; + do + { + var layer = augNodes[current].Layer; + var idx = posInLayer[current]; + var layerNodes = groups[layer]; + + if (vDown) + { + // DOWN: constrain from above — current must be below the node at idx−1. + if (idx > 0) + { + var above = layerNodes[idx - 1]; + var aboveRoot = root[above]; + PlaceBlock(aboveRoot); + + var requiredY = blockY[aboveRoot] + + innerShift[above] + + augNodes[above].Height + + NodeSpacing + - innerShift[current]; + blockY[v] = Math.Max(blockY[v], requiredY); + } + } + else + { + // UP: constrain from below — current must be above the node at idx+1. + if (idx < layerNodes.Count - 1) + { + var below = layerNodes[idx + 1]; + var belowRoot = root[below]; + PlaceBlock(belowRoot); + + var requiredY = blockY[belowRoot] + + innerShift[below] + - NodeSpacing + - augNodes[current].Height + - innerShift[current]; + blockY[v] = Math.Min(blockY[v], requiredY); + } + } + + current = align[current]; + } + while (current != v); + } + + // Trigger placement for all block roots in vdir processing order. + var layerStart = vDown ? 0 : maxLayer; + var layerEnd = vDown ? maxLayer : 0; + var layerStep = vDown ? 1 : -1; + + for (var l = layerStart; vDown ? l <= layerEnd : l >= layerEnd; l += layerStep) + { + var layer = groups[l]; + var nodeStart = vDown ? 0 : layer.Count - 1; + var nodeEnd = vDown ? layer.Count - 1 : 0; + var nodeStep = vDown ? 1 : -1; + + for (var ni = nodeStart; vDown ? ni <= nodeEnd : ni >= nodeEnd; ni += nodeStep) + { + var v = layer[ni]; + if (root[v] == v) + { + PlaceBlock(v); + } + } + } + + return blockY; + } + + /// + /// Normalizes four independent Brandes-Köpf layouts (each shifted so its minimum Y = 0) + /// and returns the per-node average of the two middle values, giving the balanced result. + /// + /// + /// Implements ELK's BKNodePlacer balanced-layout combination. Each of the four layouts + /// has a distinct direction bias (DOWN/UP × RIGHT/LEFT); the median average cancels + /// those biases while preserving the port-alignment constraints of each individual + /// layout. is added once so all returned coordinates are absolute. + /// + private static double[] BkBalancedLayout(double[][] layouts, int numAug) + { + // Normalize each layout: shift so the minimum absolute Y across all nodes is 0. + foreach (var y in layouts) + { + var minY = y.Min(); + for (var i = 0; i < numAug; i++) + { + y[i] -= minY; + } + } + + // For each node, sort the four Y values and average the two middle ones. + var finalY = new double[numAug]; + for (var i = 0; i < numAug; i++) + { + var ys = new[] { layouts[0][i], layouts[1][i], layouts[2][i], layouts[3][i] }; + Array.Sort(ys); + finalY[i] = ((ys[1] + ys[2]) / 2.0) + Padding; + } + + return finalY; + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/ComponentPacker.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/ComponentPacker.cs new file mode 100644 index 0000000..619f5e8 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/ComponentPacker.cs @@ -0,0 +1,468 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using static DemaConsulting.Rendering.Layout.Engine.Layered.LayeredLayoutMetrics; + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Composite pipeline stage that separates a disconnected graph into its connected components, +/// lays out each component independently with the wrapped inner stage sequence, then packs the +/// laid-out components side by side into a single non-overlapping arrangement. +/// +/// +/// This is a clean-room re-implementation of the documented behavior of ELK's +/// ComponentsProcessor: (a) split the graph into connected components, (b) run the full +/// layered algorithm on each component, then (c) recombine the components by packing their bounding +/// boxes. The default layered pipeline lays a disconnected graph out as if every component shared +/// the same layers, which stacks unrelated subgraphs into one tall column; packing each component +/// separately produces a compact, readable arrangement instead. +/// +/// The single-component case (including a fully connected graph or a lone node) is a transparent +/// pass-through: the inner stages run directly on the supplied graph, so the output is byte-identical +/// to running the same stages without this wrapper. This preserves the behavior of every caller whose +/// graph happens to be connected. +/// +/// +/// Component detection is deterministic — components are ordered by their lowest original node index +/// and nodes within a component by ascending original index — so renders are reproducible. +/// The stage is stateless and may be shared across pipelines. +/// +/// +internal sealed class ComponentPacker : ILayoutStage +{ + /// The inner stages run, in order, against each component sub-graph. + private readonly IReadOnlyList _innerStages; + + /// Gap, in logical pixels, left between adjacent packed components. + private readonly double _spacing; + + /// Target-aspect multiplier used to choose the packing row width. + private readonly double _aspect; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The ordered stage sequence to run on each connected component (and on the whole graph in the + /// single-component fast path). Must not be null. Typically the default ELK-layered sequence. + /// + /// Gap in logical pixels between adjacent packed components. Must be non-negative. + /// + /// Target-aspect multiplier controlling the packing row width (larger values produce wider, shorter + /// arrangements). Must be positive. + /// + /// Thrown when is null. + public ComponentPacker(IReadOnlyList innerStages, double spacing, double aspect) + { + ArgumentNullException.ThrowIfNull(innerStages); + _innerStages = innerStages; + _spacing = spacing; + _aspect = aspect; + } + + /// + /// Creates a wrapping the default ELK-layered inner stage sequence + /// (cycle breaking, layer assignment, long-edge splitting, crossing minimization, Brandes-Köpf + /// placement, port distribution, orthogonal routing, long-edge joining, and the axis transform). + /// + /// Gap in logical pixels between adjacent packed components. + /// Target-aspect multiplier controlling the packing row width. + /// A packer that lays out each component with the standard layered stages. + public static ComponentPacker WithDefaultStages(double spacing = NodeSpacing, double aspect = 1.25) => + new(DefaultInnerStages(), spacing, aspect); + + /// + /// + /// Reads for component detection and writes the real-node outputs + /// , , , + /// and — index-aligned with (and ) + /// — (one polyline per acyclic edge). An empty graph + /// ( == 0) is a no-op. + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(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) + { + return; + } + + // Partition the real nodes into connected components over the undirected edge set. + var components = FindComponents(graph); + + // Single-component fast path: run the inner stages directly so the output is byte-identical + // to the default pipeline. This covers a fully connected graph and a lone node. + if (components.Count == 1) + { + RunInner(graph); + return; + } + + LayoutAndPackComponents(graph, components); + } + + /// Builds the default ELK-layered inner stage sequence. + /// A fresh ordered list of the standard stages. + private static IReadOnlyList DefaultInnerStages() => + [ + new CycleBreaker(), + new LayerAssigner(), + new LongEdgeSplitter(), + new CrossingMinimizer(), + new BrandesKopfPlacer(), + new PortDistributor(), + new LayeredCorridorRouter(), + new LongEdgeJoiner(), + new AxisTransform(), + ]; + + /// + /// Partitions the real nodes into connected components using union-find over the undirected edge + /// set (self-loops are ignored as they connect a node only to itself). + /// + /// The graph whose nodes and edges are partitioned. + /// + /// One list of original node indices per component, components ordered by their lowest original + /// node index, nodes within each component ordered by ascending original index. + /// + private static List> FindComponents(LayeredGraph graph) + { + var n = graph.N; + var parent = new int[n]; + for (var i = 0; i < n; i++) + { + parent[i] = i; + } + + // Union the endpoints of every non-self edge so connected nodes share a representative root. + foreach (var edge in graph.Edges) + { + if (edge.Source != edge.Target) + { + Union(parent, edge.Source, edge.Target); + } + } + + // Group nodes by their representative root. Iterating nodes in ascending order makes the first + // node of each component its lowest index, so first-seen root order == ascending-min order. + var rootToComponent = new Dictionary>(); + var order = new List(); + for (var node = 0; node < n; node++) + { + var root = Find(parent, node); + if (!rootToComponent.TryGetValue(root, out var members)) + { + members = []; + rootToComponent[root] = members; + order.Add(root); + } + + members.Add(node); + } + + return [.. order.Select(root => rootToComponent[root])]; + } + + /// Finds the representative root of a node with path compression. + /// The union-find parent array. + /// The node whose root is sought. + /// The representative root index of the node's set. + private static int Find(int[] parent, int node) + { + var root = node; + while (parent[root] != root) + { + root = parent[root]; + } + + // Path compression: point every node on the walk directly at the root. + while (parent[node] != root) + { + var next = parent[node]; + parent[node] = root; + node = next; + } + + return root; + } + + /// Merges the sets containing two nodes, keeping the lower index as the root. + /// The union-find parent array. + /// First node. + /// Second node. + private static void Union(int[] parent, int a, int b) + { + var ra = Find(parent, a); + var rb = Find(parent, b); + if (ra == rb) + { + return; + } + + // Keep the lower index as the root so representatives stay deterministic. + if (ra < rb) + { + parent[rb] = ra; + } + else + { + parent[ra] = rb; + } + } + + /// Runs every wrapped inner stage, in order, against the supplied graph. + /// The graph (whole graph or a component sub-graph) to lay out in place. + private void RunInner(LayeredGraph graph) + { + foreach (var stage in _innerStages) + { + stage.Apply(graph); + } + } + + /// + /// Lays out each component on its own sub-graph, packs the component bounding boxes into shelves, + /// then merges the placed coordinates and routed waypoints back into the parent graph. + /// + /// The parent graph whose real-node outputs are written. + /// The connected components (original node indices) to lay out and pack. + private void LayoutAndPackComponents(LayeredGraph graph, List> components) + { + // Lay out every component independently on a remapped sub-graph. + var layouts = new List(components.Count); + foreach (var members in components) + { + layouts.Add(LayoutComponent(graph, members)); + } + + // Choose a packing row width and assign each component a shelf offset. + PackComponents(layouts); + + // Merge component placements and routed edges back into the parent graph in original index order. + var augX = new double[graph.N]; + var augY = new double[graph.N]; + var nodeLayers = new int[graph.N]; + + // Each component emits one polyline per ACYCLIC edge (cycle breaking drops self-loops, + // 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. + var mergedAcyclic = new List(); + var mergedReversed = new List(); + var mergedWaypoints = new List>(); + + foreach (var layout in layouts) + { + // Translate the component so its content bounding box starts at the assigned shelf offset. + var dx = -layout.MinX + layout.OffsetX; + var dy = -layout.MinY + layout.OffsetY; + + for (var local = 0; local < layout.Members.Count; local++) + { + var orig = layout.Members[local]; + augX[orig] = layout.LocalX[local] + dx; + augY[orig] = layout.LocalY[local] + dy; + nodeLayers[orig] = layout.LocalLayers[local]; + } + + for (var k = 0; k < layout.AcyclicEdges.Count; k++) + { + var localWaypoints = layout.EdgeWaypoints[k]; + var translated = new Point2D[localWaypoints.Count]; + for (var p = 0; p < localWaypoints.Count; p++) + { + translated[p] = new Point2D(localWaypoints[p].X + dx, localWaypoints[p].Y + dy); + } + + mergedAcyclic.Add(layout.AcyclicEdges[k]); + mergedReversed.Add(layout.AcyclicReversed[k]); + mergedWaypoints.Add(translated); + } + } + + graph.AugX = augX; + graph.AugY = augY; + graph.NodeLayers = nodeLayers; + graph.Acyclic = mergedAcyclic; + graph.AcyclicReversed = [.. mergedReversed]; + graph.Waypoints = mergedWaypoints; + } + + /// + /// Builds a sub-graph for one component (with a local-to-original index remap), runs the inner + /// stages on it, and captures the placed real-node coordinates, layer assignments, content + /// bounding box, and routed edge waypoints. + /// + /// The parent graph supplying node sizes, edges, and flow direction. + /// Original node indices belonging to this component (ascending order). + /// The laid-out component, normalized against its own content bounding box. + private ComponentLayout LayoutComponent(LayeredGraph graph, List members) + { + // Map original node indices to dense local indices for the sub-graph. + var origToLocal = new Dictionary(members.Count); + for (var local = 0; local < members.Count; local++) + { + origToLocal[members[local]] = local; + } + + var localNodes = new List(members.Count); + foreach (var orig in members) + { + localNodes.Add(graph.Nodes[orig]); + } + + // Collect this component's edges (in original edge order for determinism), remapping endpoints + // to local indices. + var localEdges = new List(); + for (var e = 0; e < graph.Edges.Count; e++) + { + var edge = graph.Edges[e]; + if (origToLocal.TryGetValue(edge.Source, out var localSource) && + origToLocal.TryGetValue(edge.Target, out var localTarget)) + { + localEdges.Add(new LayerEdge(localSource, localTarget)); + } + } + + // Lay out the component sub-graph with the wrapped inner stages. + var child = new LayeredGraph(localNodes, localEdges, graph.Direction); + RunInner(child); + + // 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. + var acyclicEdges = new LayerEdge[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]); + } + + // Compute the content bounding box over the real nodes only (dummies are excluded). + var minX = double.PositiveInfinity; + var minY = double.PositiveInfinity; + var maxX = double.NegativeInfinity; + var maxY = double.NegativeInfinity; + var localX = new double[members.Count]; + var localY = new double[members.Count]; + var localLayers = new int[members.Count]; + for (var local = 0; local < members.Count; local++) + { + var x = child.AugX[local]; + var y = child.AugY[local]; + localX[local] = x; + localY[local] = y; + localLayers[local] = child.NodeLayers[local]; + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x + localNodes[local].Width); + maxY = Math.Max(maxY, y + localNodes[local].Height); + } + + return new ComponentLayout + { + Members = members, + LocalX = localX, + LocalY = localY, + LocalLayers = localLayers, + MinX = minX, + MinY = minY, + Width = maxX - minX, + Height = maxY - minY, + AcyclicEdges = acyclicEdges, + AcyclicReversed = child.AcyclicReversed, + EdgeWaypoints = child.Waypoints, + }; + } + + /// + /// Assigns each component a shelf offset using a greedy row (shelf) packer. The target row width is + /// the larger of the widest component and sqrt(totalArea) * aspect, which biases the overall + /// arrangement toward the requested aspect ratio. + /// + /// The laid-out components; their offsets are assigned in place. + private void PackComponents(List layouts) + { + var totalArea = 0.0; + var widest = 0.0; + foreach (var layout in layouts) + { + totalArea += layout.Width * layout.Height; + widest = Math.Max(widest, layout.Width); + } + + var targetRowWidth = Math.Max(widest, Math.Sqrt(totalArea) * _aspect); + + var cursorX = 0.0; + var shelfTop = 0.0; + var shelfHeight = 0.0; + foreach (var layout in layouts) + { + // Wrap to a new shelf when the running row width would exceed the target (but never leave a + // shelf empty — an over-wide component sits alone on its own shelf). + if (cursorX > 0.0 && cursorX + layout.Width > targetRowWidth) + { + shelfTop += shelfHeight + _spacing; + cursorX = 0.0; + shelfHeight = 0.0; + } + + layout.OffsetX = cursorX; + layout.OffsetY = shelfTop; + cursorX += layout.Width + _spacing; + shelfHeight = Math.Max(shelfHeight, layout.Height); + } + } + + /// The laid-out result of one connected component, normalized to its own bounding box. + private sealed class ComponentLayout + { + /// Original node indices in this component, ascending. + public required IReadOnlyList Members { get; init; } + + /// Placed X coordinate of each real node, by local index. + public required double[] LocalX { get; init; } + + /// Placed Y coordinate of each real node, by local index. + public required double[] LocalY { get; init; } + + /// Assigned layer of each real node, by local index. + public required int[] LocalLayers { get; init; } + + /// Left edge of the component's content bounding box. + public required double MinX { get; init; } + + /// Top edge of the component's content bounding box. + public required double MinY { get; init; } + + /// Width of the component's content bounding box. + public required double Width { get; init; } + + /// Height of the component's content bounding box. + public required double Height { get; init; } + + /// Acyclic edges (original node indices), index-aligned with . + public required IReadOnlyList AcyclicEdges { get; init; } + + /// + /// Whether each acyclic edge was produced by reversing a back edge, index-aligned with + /// . + /// + public required IReadOnlyList AcyclicReversed { get; init; } + + /// Routed waypoints for each acyclic edge, in order. + public required IReadOnlyList> EdgeWaypoints { get; init; } + + /// Packed shelf X offset assigned to this component. + public double OffsetX { get; set; } + + /// Packed shelf Y offset assigned to this component. + public double OffsetY { get; set; } + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/CrossingMinimizer.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/CrossingMinimizer.cs new file mode 100644 index 0000000..f805ef9 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/CrossingMinimizer.cs @@ -0,0 +1,110 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// +using static DemaConsulting.Rendering.Layout.Engine.Layered.LayeredLayoutMetrics; + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Pipeline stage that reduces edge crossings via Barycenter ordering over the augmented graph. +/// +internal sealed class CrossingMinimizer : ILayoutStage +{ + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + graph.Groups = GroupByLayerAug(graph.AugNodes); + OrderLayersAug(graph.Groups, graph.AugNodes.Count, graph.AugEdges); + } + + /// Groups augmented-node indices by layer. + private static List> GroupByLayerAug(List augNodes) + { + var maxLayer = augNodes.Max(a => a.Layer); + var groups = new List>(maxLayer + 1); + for (var l = 0; l <= maxLayer; l++) + { + groups.Add([]); + } + + for (var i = 0; i < augNodes.Count; i++) + { + groups[augNodes[i].Layer].Add(i); + } + + return groups; + } + + /// + /// Runs Barycenter sweeps over the augmented graph + /// (real nodes and dummies) to reduce edge crossings. + /// + private static void OrderLayersAug(List> groups, int numAug, List augEdges) + { + var leftNeighbors = new List[numAug]; + var rightNeighbors = new List[numAug]; + for (var i = 0; i < numAug; i++) + { + leftNeighbors[i] = []; + rightNeighbors[i] = []; + } + + foreach (var ae in augEdges) + { + rightNeighbors[ae.Source].Add(ae.Target); + leftNeighbors[ae.Target].Add(ae.Source); + } + + for (var sweep = 0; sweep < BarycentricSweeps; sweep++) + { + if (sweep % 2 == 0) + { + for (var l = 1; l < groups.Count; l++) + { + SortByBarycenter(groups[l], groups[l - 1], leftNeighbors); + } + } + else + { + for (var l = groups.Count - 2; l >= 0; l--) + { + SortByBarycenter(groups[l], groups[l + 1], rightNeighbors); + } + } + } + } + + /// + /// Sorts by the average position of each node's neighbors in + /// ; nodes without neighbors keep their current relative order. + /// + private static void SortByBarycenter(List layer, List adjacentLayer, List[] neighbors) + { + var position = new Dictionary(); + for (var i = 0; i < adjacentLayer.Count; i++) + { + position[adjacentLayer[i]] = i; + } + + var keyed = new List<(int Node, double Key, int Original)>(layer.Count); + for (var i = 0; i < layer.Count; i++) + { + var node = layer[i]; + var ns = neighbors[node].Where(position.ContainsKey).ToList(); + var key = ns.Count > 0 ? ns.Average(x => position[x]) : i; + keyed.Add((node, key, i)); + } + + keyed.Sort((a, b) => + { + var c = a.Key.CompareTo(b.Key); + return c != 0 ? c : a.Original.CompareTo(b.Original); + }); + + for (var i = 0; i < layer.Count; i++) + { + layer[i] = keyed[i].Node; + } + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/CycleBreaker.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/CycleBreaker.cs new file mode 100644 index 0000000..b7466b0 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/CycleBreaker.cs @@ -0,0 +1,101 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Pipeline stage that makes the input graph acyclic by reversing cycle-causing back edges, +/// following ELK's cycle-breaking phase. +/// +internal sealed class CycleBreaker : ILayoutStage +{ + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + var (acyclic, reversed) = BreakCycles(graph.N, graph.Edges); + graph.Acyclic = acyclic; + graph.AcyclicReversed = reversed; + } + + /// + /// Returns the edge set with cycle-causing back edges reversed, using DFS to classify any + /// edge to a node still on the recursion stack as a back edge. The second tuple element is a + /// parallel flag array marking which retained edges were produced by reversing a back edge. + /// + private static (List Acyclic, bool[] Reversed) BreakCycles(int n, IReadOnlyList edges) + { + var adjacency = new List[n]; + for (var i = 0; i < n; i++) + { + adjacency[i] = []; + } + + foreach (var e in edges) + { + if (e.Source != e.Target) + { + adjacency[e.Source].Add(e.Target); + } + } + + var visited = new bool[n]; + var onStack = new bool[n]; + var backEdges = new HashSet<(int, int)>(); + + void Dfs(int u) + { + visited[u] = true; + onStack[u] = true; + foreach (var v in adjacency[u]) + { + if (onStack[v]) + { + backEdges.Add((u, v)); + } + else if (!visited[v]) + { + Dfs(v); + } + } + + // S4143: standard DFS coloring — onStack[u] is read by recursive calls between the + // true/false assignments; the analyzer cannot see across the recursion. +#pragma warning disable S4143 + onStack[u] = false; +#pragma warning restore S4143 + } + + for (var i = 0; i < n; i++) + { + if (!visited[i]) + { + Dfs(i); + } + } + + var result = new List(); + var reversed = new List(); + var seen = new HashSet<(int, int)>(); + foreach (var e in edges) + { + if (e.Source == e.Target) + { + continue; + } + + var isBack = backEdges.Contains((e.Source, e.Target)); + var (from, to) = isBack + ? (e.Target, e.Source) + : (e.Source, e.Target); + + if (from != to && seen.Add((from, to))) + { + result.Add(new LayerEdge(from, to)); + reversed.Add(isBack); + } + } + + return (result, [.. reversed]); + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs new file mode 100644 index 0000000..91a8eff --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs @@ -0,0 +1,22 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// How a layered layout treats nested (compound) nodes. +/// +/// +/// Only is exercised by the current behavior-preserving extraction; +/// is reserved scaffolding for a later phase that lays out each +/// container's children bottom-up and treats each container as a fixed-size atomic node. +/// +internal enum HierarchyHandling +{ + /// Run the pipeline once over a single flat graph. + Flat, + + /// Run the flat pipeline per container, bottom-up (reserved for a later phase). + Recursive, +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/ILayoutStage.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/ILayoutStage.cs new file mode 100644 index 0000000..c2b06be --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/ILayoutStage.cs @@ -0,0 +1,22 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// A single composable phase of the layered layout pipeline. +/// +/// +/// Each stage reads the state it needs from the shared and writes its +/// results back onto the same instance, so stages can be ordered, replaced, and unit-tested in +/// isolation. Stages are stateless and may be shared across pipelines. +/// +internal interface ILayoutStage +{ + /// + /// Applies this stage to the supplied graph, mutating it in place. + /// + /// The shared layered-layout state to read from and write to. + void Apply(LayeredGraph graph); +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayerAssigner.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayerAssigner.cs new file mode 100644 index 0000000..db81bb7 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayerAssigner.cs @@ -0,0 +1,63 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Pipeline stage that assigns each node to a layer using longest-path layering. +/// +internal sealed class LayerAssigner : ILayoutStage +{ + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + graph.NodeLayers = AssignLayers(graph.N, graph.Acyclic); + } + + /// + /// Assigns each node to a layer equal to the length of its longest incoming path + /// (sources at layer 0, sinks at the maximum layer). + /// + private static int[] AssignLayers(int n, List edges) + { + var outgoing = new List[n]; + var inDegree = new int[n]; + for (var i = 0; i < n; i++) + { + outgoing[i] = []; + } + + foreach (var e in edges) + { + outgoing[e.Source].Add(e.Target); + inDegree[e.Target]++; + } + + var layer = new int[n]; + var queue = new Queue(); + for (var i = 0; i < n; i++) + { + if (inDegree[i] == 0) + { + queue.Enqueue(i); + } + } + + var remaining = (int[])inDegree.Clone(); + while (queue.Count > 0) + { + var u = queue.Dequeue(); + foreach (var v in outgoing[u]) + { + layer[v] = Math.Max(layer[v], layer[u] + 1); + if (--remaining[v] == 0) + { + queue.Enqueue(v); + } + } + } + + return layer; + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs new file mode 100644 index 0000000..bb194f9 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs @@ -0,0 +1,311 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// +using static DemaConsulting.Rendering.Layout.Engine.Layered.LayeredLayoutMetrics; + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Pipeline stage that routes every corridor using ELK's OrthogonalRoutingGenerator slot +/// algorithm, producing the orthogonal bend points for each augmented sub-edge. +/// +internal sealed class LayeredCorridorRouter : ILayoutStage +{ + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + var augNodes = graph.AugNodes; + var augEdges = graph.AugEdges; + var columnX = graph.ColumnX; + var maxColWidth = graph.MaxColWidth; + var augPortYSrc = graph.AugPortYSrc; + var augPortYTgt = graph.AugPortYTgt; + var numAugEdges = augEdges.Count; + + // Route each corridor using ELK's slot algorithm. + var layerCount = columnX.Length; + var augBendPoints = new List[numAugEdges]; + for (var ei = 0; ei < numAugEdges; ei++) + { + augBendPoints[ei] = []; + } + + for (var l = 0; l + 1 < layerCount; l++) + { + // Collect sub-edges whose source is in layer l. + var corridorEdges = augEdges + .Select((ae, i) => (ae, i)) + .Where(x => augNodes[x.ae.Source].Layer == l) + .ToList(); + + if (corridorEdges.Count == 0) + { + continue; + } + + // First slot starts one ConnectorClearance past the right edge of the source column. + var startPos = columnX[l] + maxColWidth[l] + ConnectorClearance; + + // Build routing segments. + var segments = corridorEdges + .Select(x => + { + var srcY = augPortYSrc[x.i]; + var tgtY = augPortYTgt[x.i]; + return new Segment + { + AugEdgeIndex = x.i, + SourceY = srcY, + TargetY = tgtY, + Lo = Math.Min(srcY, tgtY), + Hi = Math.Max(srcY, tgtY), + }; + }) + .ToList(); + + // Build crossing-based dependencies between segment pairs (ELK countCrossings). + for (var i = 0; i < segments.Count - 1; i++) + { + for (var j = i + 1; j < segments.Count; j++) + { + CreateDependency(segments[i], segments[j]); + } + } + + // Break cycles in the dependency graph by removing back edges. + BreakSegmentCycles(segments); + + // Assign routing slots via topological BFS (ELK topologicalNumbering). + TopologicalNumbering(segments); + + // Emit bend points: ELK WestToEastRoutingStrategy.calculateBendPoints. + foreach (var seg in segments) + { + if (Math.Abs(seg.SourceY - seg.TargetY) < StraightTolerance) + { + // Straight edge: no bend points, no slot consumed. + continue; + } + + var segX = startPos + (seg.RoutingSlot * EdgeSpacing); + + // Reversed (back) edges are stored flipped, so the consumer draws the end marker on + // the augmented-source face of the first sub-edge (whose source is the real node, not + // a long-edge dummy). For that sub-edge the wrap-around corridor is the final straight + // approach into the true target, and at the default slot it is only one + // ConnectorClearance wide. Guarantee that approach is at least + // graph.BackEdgeEntryApproach so the rounded corner never intrudes into the end + // decoration. Math.Max only ever pushes the jog outward, and at the default + // (BackEdgeEntryApproach == ConnectorClearance == startPos offset) it is a no-op, so + // forward edges (and every other sub-edge) are byte-identical. It runs in abstract + // RIGHT-equivalent coordinates so AxisTransform maps it to any requested direction. + var aug = augEdges[seg.AugEdgeIndex]; + if (graph.AcyclicReversed[aug.OrigEdgeIndex] && !augNodes[aug.Source].IsDummy) + { + segX = Math.Max(segX, columnX[l] + maxColWidth[l] + graph.BackEdgeEntryApproach); + } + + augBendPoints[seg.AugEdgeIndex] = + [ + new Point2D(segX, seg.SourceY), + new Point2D(segX, seg.TargetY), + ]; + } + } + + graph.AugBendPoints = augBendPoints; + } + + /// + /// Creates a directed dependency between two segments based on ELK's crossing-count heuristic + /// (countCrossings): the segment whose left placement causes fewer crossings becomes the + /// source (lower slot) of the dependency. + /// + /// + /// For 1:1 edges each segment has exactly one EAST port (SourceY, ELK incomingConnectionCoordinates) + /// and one WEST port (TargetY, ELK outgoingConnectionCoordinates). The crossing count is computed + /// per ELK's countCrossings(posis, start, end): number of positions in posis that fall + /// within [start, end]. + /// + private static void CreateDependency(Segment s1, Segment s2) + { + // crossings1: cost of placing s1 LEFT of s2. + // outgoing(s1)=[s1.TargetY] ∩ [s2.Lo, s2.Hi] → does s1's exit cross s2's extent? + // incoming(s2)=[s2.SourceY] ∩ [s1.Lo, s1.Hi] → does s2's entry fall inside s1's extent? + var c1 = CountCrossings(s1.TargetY, s2.Lo, s2.Hi) + + CountCrossings(s2.SourceY, s1.Lo, s1.Hi); + + // crossings2: cost of placing s2 LEFT of s1. + // outgoing(s2)=[s2.TargetY] ∩ [s1.Lo, s1.Hi] + // incoming(s1)=[s1.SourceY] ∩ [s2.Lo, s2.Hi] + var c2 = CountCrossings(s2.TargetY, s1.Lo, s1.Hi) + + CountCrossings(s1.SourceY, s2.Lo, s2.Hi); + + if (c1 < c2) + { + // s1 prefers to be left of s2. + _ = new SegDep(s1, s2, c2 - c1); + } + else if (c2 < c1) + { + // s2 prefers to be left of s1. + _ = new SegDep(s2, s1, c1 - c2); + } + else if (c1 > 0) + { + // Equal non-zero crossings: unavoidable conflict — pick s1 left (deterministic tie-break). + _ = new SegDep(s1, s2, 0); + } + + // c1 == c2 == 0: both orderings cross-free; no dependency needed. + } + + /// + /// Returns 1 if falls within [, + /// ], 0 otherwise. Implements ELK's single-position + /// countCrossings. + /// + private static int CountCrossings(double pos, double lo, double hi) + => (pos >= lo && pos <= hi) ? 1 : 0; + + /// + /// Detects and removes back edges in the dependency graph using DFS coloring, following + /// ELK's breakNonCriticalCycles. Removing a back edge accepts one additional crossing + /// in exchange for an acyclic ordering. + /// + private static void BreakSegmentCycles(List segments) + { + // 0 = unvisited, 1 = on stack (gray), 2 = done (black). + var color = new Dictionary(); + + void Dfs(Segment s) + { + color[s] = 1; + + // Snapshot the list before iterating to allow safe removal. + foreach (var dep in s.Outgoing.ToList()) + { + var t = dep.Target; + if (!color.TryGetValue(t, out var c)) + { + color[t] = 0; + Dfs(t); + } + else if (c == 1) + { + // Back edge detected: remove it to break the cycle. + dep.Remove(); + } + } + + // S4143: standard DFS coloring across recursion boundary. +#pragma warning disable S4143 + color[s] = 2; +#pragma warning restore S4143 + } + + foreach (var s in segments.Where(s => !color.ContainsKey(s))) + { + color[s] = 0; + Dfs(s); + } + } + + /// + /// Assigns routing slots to segments by topological BFS, implementing ELK's + /// topologicalNumbering. Each segment's slot equals the maximum predecessor slot + /// plus one (Kahn's algorithm on the dependency DAG). + /// + private static void TopologicalNumbering(List segments) + { + // Reset weights from the current (post-cycle-breaking) dependency graph. + foreach (var s in segments) + { + s.InWeight = s.Incoming.Count; + s.RoutingSlot = 0; + } + + var sources = segments.Where(s => s.InWeight == 0).ToList(); + + while (sources.Count > 0) + { + var node = sources[0]; + sources.RemoveAt(0); + + foreach (var tgt in node.Outgoing.Select(dep => dep.Target)) + { + tgt.RoutingSlot = Math.Max(tgt.RoutingSlot, node.RoutingSlot + 1); + tgt.InWeight--; + if (tgt.InWeight == 0) + { + sources.Add(tgt); + } + } + } + + // Any segments unreachable due to remaining cycles (should not occur after cycle-breaking) + // keep slot 0 and will be routed at the first available position. + } + + /// + /// A routing segment: one sub-edge crossing a single corridor, carrying its ELK port + /// coordinates and the slot assigned by topological numbering. + /// + private sealed class Segment + { + public int AugEdgeIndex; + + /// Y coordinate of the EAST (source-layer) port (ELK incomingConnectionCoordinates). + public double SourceY; + + /// Y coordinate of the WEST (target-layer) port (ELK outgoingConnectionCoordinates). + public double TargetY; + + /// Min of SourceY and TargetY, used for ELK crossing-count computation. + public double Lo; + + /// Max of SourceY and TargetY, used for ELK crossing-count computation. + public double Hi; + + /// Assigned routing slot (column offset within corridor). Set by topological numbering. + public int RoutingSlot; + + /// Remaining in-degree during topological BFS; initialized from Incoming.Count. + public int InWeight; + + + /// Dependencies for which this segment must precede the target. + public List Outgoing { get; } = []; + + /// Dependencies for which a predecessor must precede this segment. + public List Incoming { get; } = []; + } + + /// + /// A directed dependency between two routing segments: the source segment prefers + /// to occupy a lower slot than the target segment (ELK HyperEdgeSegmentDependency). + /// + private sealed class SegDep + { + public Segment Source { get; } + public Segment Target { get; } + public int Weight { get; } + + public SegDep(Segment src, Segment tgt, int weight) + { + Source = src; + Target = tgt; + Weight = weight; + src.Outgoing.Add(this); + tgt.Incoming.Add(this); + } + + /// Removes this dependency from both endpoints' adjacency lists. + public void Remove() + { + Source.Outgoing.Remove(this); + Target.Incoming.Remove(this); + } + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs new file mode 100644 index 0000000..3aa0fba --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs @@ -0,0 +1,159 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// A node in the augmented Sugiyama graph (real part box or long-edge dummy). +/// Width of the node's bounding box in logical pixels. +/// Height of the node's bounding box in logical pixels. +/// Assigned Sugiyama layer index. +/// Whether this node is a zero-size long-edge dummy. +internal sealed record AugNode(double Width, double Height, int Layer, bool IsDummy = false); + +/// A sub-edge in the augmented graph after long-edge splitting. +/// Index of the source augmented node. +/// Index of the target augmented node. +/// Index of the original (pre-split) edge this sub-edge belongs to. +internal readonly record struct AugEdge(int Source, int Target, int OrigEdgeIndex); + +/// +/// The mutable shared state threaded through every of the layered +/// pipeline. Each stage reads the fields produced by earlier stages and writes the fields it owns. +/// +/// +/// This object replaces the ad-hoc local variables that the monolithic interconnection engine +/// passed between its private phase methods, while preserving exactly the same intermediate values +/// (and therefore the same floating-point results). +/// +internal sealed class LayeredGraph +{ + /// + /// Initializes a new instance of the class. + /// + /// Input nodes to place, in caller order. + /// Directed edges between nodes (by index). + /// The requested layout flow direction. + public LayeredGraph( + IReadOnlyList nodes, + IReadOnlyList edges, + LayoutDirection direction) + { + ArgumentNullException.ThrowIfNull(nodes); + ArgumentNullException.ThrowIfNull(edges); + + Nodes = nodes; + Edges = edges; + Direction = direction; + N = nodes.Count; + } + + /// Gets the number of real input nodes. + public int N { get; } + + /// Gets the input nodes, in caller order. + /// + /// The setter is private; the only in-place mutation is , the seam + /// used by to feed the direction-agnostic stages + /// node sizes whose along-extent matches the requested flow direction. + /// + public IReadOnlyList Nodes { get; private set; } + + /// Gets the directed input edges (by node index). + public IReadOnlyList Edges { get; } + + /// Gets the requested layout flow direction. + public LayoutDirection Direction { get; } + + /// + /// 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 + /// end marker. + /// + /// + /// The default is , which exactly reproduces + /// the original engine: the router's first slot already starts one + /// past the source column, so the + /// Math.Max clamp in is a no-op at the default and forward + /// geometry stays byte-identical. A consumer that draws a longer end decoration (for example the + /// state-transition view's open chevron) raises this so the rounded corner never intrudes into the + /// decoration. + /// + public double BackEdgeEntryApproach { get; set; } = LayeredLayoutMetrics.ConnectorClearance; + + /// Gets or sets the acyclic edge set after cycle breaking. + public List Acyclic { get; set; } = []; + + /// + /// Gets or sets, parallel to (same index order), whether each retained + /// acyclic edge was produced by reversing a cycle-causing back edge. + /// + /// + /// records this flag so later stages can recognize edges whose true + /// direction was flipped for layering. reads it to guarantee a + /// minimum entry approach for the arrowhead that the consumer draws on the (un-reversed) target. + /// + public bool[] AcyclicReversed { get; set; } = []; + + /// Gets or sets the assigned layer index for each real node, in node order. + public int[] NodeLayers { get; set; } = []; + + /// Gets or sets the augmented nodes (real boxes followed by long-edge dummies). + public List AugNodes { get; set; } = []; + + /// Gets or sets the augmented sub-edges produced by long-edge splitting. + public List AugEdges { get; set; } = []; + + /// Gets or sets the augmented-node indices grouped (and ordered) by layer. + public List> Groups { get; set; } = []; + + /// Gets or sets the X coordinate of each augmented node. + public double[] AugX { get; set; } = []; + + /// Gets or sets the Y coordinate of each augmented node. + public double[] AugY { get; set; } = []; + + /// Gets or sets the left X coordinate of each layer column. + public double[] ColumnX { get; set; } = []; + + /// Gets or sets the maximum real-node width per layer column. + public double[] MaxColWidth { get; set; } = []; + + /// Gets or sets the source-side (right face) port Y for each augmented sub-edge. + public double[] AugPortYSrc { get; set; } = []; + + /// Gets or sets the target-side (left face) port Y for each augmented sub-edge. + public double[] AugPortYTgt { get; set; } = []; + + /// Gets or sets the orthogonal bend points for each augmented sub-edge. + public List[] AugBendPoints { get; set; } = []; + + /// Gets or sets the assembled orthogonal waypoints for each original (acyclic) edge. + public IReadOnlyList> Waypoints { get; set; } = []; + + /// + /// Swaps each input node's and . + /// + /// + /// The direction-agnostic stages always treat a node's width as its along-axis (layer + /// progression) extent and its height as its cross-axis (within-layer) extent. For a top-to-bottom + /// () or bottom-to-top () flow, + /// the along-axis must instead be the node height, so + /// calls this seam before the stages run. It is never invoked for the + /// / paths, which keeps those + /// outputs byte-identical. + /// + public void SwapNodeAxes() + { + var swapped = new LayerNode[Nodes.Count]; + for (var i = 0; i < Nodes.Count; i++) + { + // S2234: width and height are deliberately swapped so the stages space layers by height. +#pragma warning disable S2234 + swapped[i] = new LayerNode(Nodes[i].Height, Nodes[i].Width); +#pragma warning restore S2234 + } + + Nodes = swapped; + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutMetrics.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutMetrics.cs new file mode 100644 index 0000000..9624ab3 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutMetrics.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Fixed spacing and tolerance constants shared by every layered-layout stage. +/// +/// +/// These values are intentionally identical to the constants previously embedded in the +/// monolithic interconnection engine; the extraction preserves them exactly so the pipeline +/// reproduces the legacy output byte for byte. +/// +internal static class LayeredLayoutMetrics +{ + /// Vertical gap between adjacent nodes stacked within the same layer. + internal const double NodeSpacing = 30.0; + + /// Minimum corridor width (node-node spacing) between adjacent columns. + internal const double CorridorMinWidth = 70.0; + + /// Slot-to-slot spacing within a corridor (ELK edgeEdgeSpacing). + internal const double EdgeSpacing = 16.0; + + /// Clearance from corridor edge to the nearest routing slot (ELK edgeNodeSpacing). + internal const double ConnectorClearance = 10.0; + + /// Uniform padding added around the placed content. + internal const double Padding = 20.0; + + /// Number of Barycenter ordering sweeps (down + up = one round). + internal const int BarycentricSweeps = 4; + + /// Tolerance for treating a segment as straight (no bend points needed). + internal const double StraightTolerance = 1e-6; +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs new file mode 100644 index 0000000..67f2dbf --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs @@ -0,0 +1,127 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// An ordered sequence of instances that, when run, transforms a +/// from raw nodes and edges into a fully placed and routed layout. +/// +/// +/// Pipelines are assembled with the fluent returned by +/// . The default stage sequence reproduces ELK's layered algorithm in the +/// order used by the original interconnection engine. +/// +internal sealed class LayeredLayoutPipeline +{ + private readonly IReadOnlyList _stages; + + private LayeredLayoutPipeline( + LayoutDirection direction, + HierarchyHandling hierarchy, + IReadOnlyList stages) + { + Direction = direction; + Hierarchy = hierarchy; + _stages = stages; + } + + /// Gets the layout flow direction this pipeline was built for. + public LayoutDirection Direction { get; } + + /// Gets the hierarchy-handling mode this pipeline was built for. + public HierarchyHandling Hierarchy { get; } + + /// Creates a new . + /// A fresh builder with default direction and hierarchy. + public static PipelineBuilder Builder() => new(); + + /// Runs every stage, in order, against the supplied graph. + /// The graph to lay out; mutated in place. + public void Run(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + + // Normalize the input node axes for the requested direction before any stage runs, so the + // direction-agnostic stages space layers by the correct extent (a no-op for RIGHT/LEFT). + AxisTransform.NormalizeInputAxes(graph); + + foreach (var stage in _stages) + { + stage.Apply(graph); + } + } + + /// + /// Fluent builder that assembles a from an ordered list + /// of stages plus a direction and hierarchy-handling selection. + /// + internal sealed class PipelineBuilder + { + private readonly List _stages = []; + private LayoutDirection _direction = LayoutDirection.Right; + private HierarchyHandling _hierarchy = HierarchyHandling.Flat; + + /// Sets the layout flow direction. + /// The desired direction. + /// This builder, for chaining. + public PipelineBuilder Direction(LayoutDirection direction) + { + _direction = direction; + return this; + } + + /// Sets the hierarchy-handling mode. + /// The desired hierarchy handling. + /// This builder, for chaining. + public PipelineBuilder Hierarchy(HierarchyHandling hierarchy) + { + _hierarchy = hierarchy; + return this; + } + + /// Appends a single stage to the pipeline. + /// The stage to append. + /// This builder, for chaining. + public PipelineBuilder AddStage(ILayoutStage stage) + { + ArgumentNullException.ThrowIfNull(stage); + _stages.Add(stage); + return this; + } + + /// + /// Appends the default ELK-layered stage sequence: cycle breaking, layer assignment, + /// long-edge splitting, crossing minimization, Brandes-Kopf placement, port distribution, + /// orthogonal routing, long-edge joining, and the final axis transform. + /// + /// This builder, for chaining. + public PipelineBuilder AddDefaultStages() + { + _stages.Add(new CycleBreaker()); + _stages.Add(new LayerAssigner()); + _stages.Add(new LongEdgeSplitter()); + _stages.Add(new CrossingMinimizer()); + _stages.Add(new BrandesKopfPlacer()); + _stages.Add(new PortDistributor()); + _stages.Add(new LayeredCorridorRouter()); + _stages.Add(new LongEdgeJoiner()); + _stages.Add(new AxisTransform()); + return this; + } + + /// Builds the configured pipeline. + /// A new . + public LayeredLayoutPipeline Build() + { + if (_hierarchy == HierarchyHandling.Recursive) + { + throw new NotSupportedException( + "Recursive hierarchy handling is not yet supported."); + } + + return new LayeredLayoutPipeline(_direction, _hierarchy, _stages.ToArray()); + } + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayoutDirection.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayoutDirection.cs new file mode 100644 index 0000000..825d0b3 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayoutDirection.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// The primary flow direction of a layered layout. +/// +/// +/// Stages always compute in the RIGHT-equivalent abstract axes (along = layer progression on +/// the +X axis, cross = within-layer on the +Y axis). A final stage +/// rotates or flips those abstract coordinates onto screen coordinates for the requested +/// direction, isolating all direction handling to a single unit. +/// +internal enum LayoutDirection +{ + /// Layers progress left-to-right (the abstract, identity direction). + Right, + + /// Layers progress top-to-bottom. + Down, + + /// Layers progress right-to-left. + Left, + + /// Layers progress bottom-to-top. + Up, +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeJoiner.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeJoiner.cs new file mode 100644 index 0000000..d35e4ab --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeJoiner.cs @@ -0,0 +1,81 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Pipeline stage that assembles per-original-edge orthogonal waypoints by concatenating the bend +/// points of each sub-edge in source-to-target layer order (ELK's LongEdgeJoiner). +/// +internal sealed class LongEdgeJoiner : ILayoutStage +{ + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + var augNodes = graph.AugNodes; + var augEdges = graph.AugEdges; + var augX = graph.AugX; + var augPortYSrc = graph.AugPortYSrc; + var augPortYTgt = graph.AugPortYTgt; + var augBendPoints = graph.AugBendPoints; + var numAugEdges = augEdges.Count; + var numOrigEdges = graph.Acyclic.Count; + + // Assemble per-original-edge waypoints from sub-edge bend points (ELK LongEdgeJoiner). + var subEdgesByOrig = new List[numOrigEdges]; + for (var ei = 0; ei < numOrigEdges; ei++) + { + subEdgesByOrig[ei] = []; + } + + for (var ei = 0; ei < numAugEdges; ei++) + { + subEdgesByOrig[augEdges[ei].OrigEdgeIndex].Add(ei); + } + + // Sub-edges must be in source-to-target layer order for concatenation. + for (var ei = 0; ei < numOrigEdges; ei++) + { + subEdgesByOrig[ei].Sort((a, b) => + augNodes[augEdges[a].Source].Layer.CompareTo(augNodes[augEdges[b].Source].Layer)); + } + + var result = new IReadOnlyList[numOrigEdges]; + for (var origIdx = 0; origIdx < numOrigEdges; origIdx++) + { + var subEdges = subEdgesByOrig[origIdx]; + if (subEdges.Count == 0) + { + result[origIdx] = []; + continue; + } + + var firstSubEdge = augEdges[subEdges[0]]; + var lastSubEdge = augEdges[subEdges[^1]]; + + var srcNodeIdx = firstSubEdge.Source; + var tgtNodeIdx = lastSubEdge.Target; + + var srcRight = augX[srcNodeIdx] + augNodes[srcNodeIdx].Width; + var tgtLeft = augX[tgtNodeIdx]; + var srcPortY = augPortYSrc[subEdges[0]]; + var tgtPortY = augPortYTgt[subEdges[^1]]; + + var wps = new List + { + new(srcRight, srcPortY), + }; + + foreach (var subEdgeIdx in subEdges) + { + wps.AddRange(augBendPoints[subEdgeIdx]); + } + + wps.Add(new Point2D(tgtLeft, tgtPortY)); + result[origIdx] = wps; + } + + graph.Waypoints = result; + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeSplitter.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeSplitter.cs new file mode 100644 index 0000000..a511ba8 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeSplitter.cs @@ -0,0 +1,71 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Pipeline stage that splits every multi-layer edge into a chain of unit-span sub-edges by +/// inserting one zero-size dummy node per intermediate layer (ELK's LongEdgeSplitter). +/// +internal sealed class LongEdgeSplitter : ILayoutStage +{ + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + var (augNodes, augEdges) = InsertLongEdgeDummies(graph.N, graph.Nodes, graph.NodeLayers, graph.Acyclic); + graph.AugNodes = augNodes; + graph.AugEdges = augEdges; + } + + /// + /// Splits every edge spanning more than one layer into a chain of unit-span sub-edges by + /// inserting one zero-size dummy node at each intermediate layer, following + /// ELK's LongEdgeSplitter phase. + /// + private static (List AugNodes, List AugEdges) InsertLongEdgeDummies( + int n, + IReadOnlyList nodes, + int[] nodeLayers, + List acyclic) + { + var augNodes = new List(n + acyclic.Count); + for (var i = 0; i < n; i++) + { + augNodes.Add(new AugNode(nodes[i].Width, nodes[i].Height, nodeLayers[i])); + } + + var augEdges = new List(acyclic.Count * 2); + for (var e = 0; e < acyclic.Count; e++) + { + var edge = acyclic[e]; + var span = nodeLayers[edge.Target] - nodeLayers[edge.Source]; + + if (span <= 0) + { + continue; + } + + if (span == 1) + { + augEdges.Add(new AugEdge(edge.Source, edge.Target, e)); + } + else + { + // Chain: src → d1 → d2 → … → tgt with one dummy per intermediate layer. + var prev = edge.Source; + for (var l = nodeLayers[edge.Source] + 1; l < nodeLayers[edge.Target]; l++) + { + var dIdx = augNodes.Count; + augNodes.Add(new AugNode(0.0, 0.0, l, IsDummy: true)); + augEdges.Add(new AugEdge(prev, dIdx, e)); + prev = dIdx; + } + + augEdges.Add(new AugEdge(prev, edge.Target, e)); + } + } + + return (augNodes, augEdges); + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/PortDistributor.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/PortDistributor.cs new file mode 100644 index 0000000..7804b6e --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/PortDistributor.cs @@ -0,0 +1,131 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// +using static DemaConsulting.Rendering.Layout.Engine.Layered.LayeredLayoutMetrics; + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// Pipeline stage that distributes connector ports evenly along each box face and records the +/// source-side and target-side port Y coordinate for every augmented sub-edge. +/// +internal sealed class PortDistributor : ILayoutStage +{ + /// + public void Apply(LayeredGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + var n = graph.N; + var nodes = graph.Nodes; + var augNodes = graph.AugNodes; + var augEdges = graph.AugEdges; + var augY = graph.AugY; + var numAugEdges = augEdges.Count; + + // Port Y values: augPortYSrc[i] = source (right face) Y; augPortYTgt[i] = target (left face) Y. + var augPortYSrc = new double[numAugEdges]; + var augPortYTgt = new double[numAugEdges]; + + // Distribute outgoing (source-side) ports on each real node's right face. + var outByNode = new Dictionary>(); + for (var ei = 0; ei < numAugEdges; ei++) + { + var src = augEdges[ei].Source; + if (!outByNode.TryGetValue(src, out var list)) + { + list = []; + outByNode[src] = list; + } + + list.Add(ei); + } + + foreach (var (ni, edgeList) in outByNode) + { + if (augNodes[ni].IsDummy) + { + // Dummies pass the wire straight through at their own Y. + foreach (var ei in edgeList) + { + augPortYSrc[ei] = augY[ni]; + } + } + else + { + // Sort by target Y center, then edge index for stability. + var sorted = edgeList + .OrderBy(ei => augY[augEdges[ei].Target] + (augNodes[augEdges[ei].Target].Height / 2.0)) + .ThenBy(ei => ei) + .ToList(); + DistributePorts(sorted, augY[ni], nodes[ni].Height, augPortYSrc); + } + } + + // Distribute incoming (target-side) ports on each real node's left face. + var inByNode = new Dictionary>(); + for (var ei = 0; ei < numAugEdges; ei++) + { + var tgt = augEdges[ei].Target; + if (!inByNode.TryGetValue(tgt, out var list)) + { + list = []; + inByNode[tgt] = list; + } + + list.Add(ei); + } + + foreach (var (ni, edgeList) in inByNode) + { + if (augNodes[ni].IsDummy) + { + foreach (var ei in edgeList) + { + augPortYTgt[ei] = augY[ni]; + } + } + else + { + var sorted = edgeList + .OrderBy(ei => augY[augEdges[ei].Source] + (augNodes[augEdges[ei].Source].Height / 2.0)) + .ThenBy(ei => ei) + .ToList(); + DistributePorts(sorted, augY[ni], nodes[ni < n ? ni : 0].Height, augPortYTgt); + } + } + + graph.AugPortYSrc = augPortYSrc; + graph.AugPortYTgt = augPortYTgt; + } + + /// + /// Evenly distributes port Y positions along a node face, with + /// inset from the top and bottom edges. + /// + private static void DistributePorts( + IReadOnlyList sortedEdgeIndices, + double nodeTop, + double nodeHeight, + double[] portY) + { + var count = sortedEdgeIndices.Count; + for (var k = 0; k < count; k++) + { + double y; + if (count == 1) + { + y = nodeTop + (nodeHeight / 2.0); + } + else + { + var usable = nodeHeight - (2.0 * ConnectorClearance); + y = nodeTop + ConnectorClearance + (k * usable / (count - 1)); + } + + portY[sortedEdgeIndices[k]] = Math.Clamp( + y, + nodeTop + ConnectorClearance, + nodeTop + nodeHeight - ConnectorClearance); + } + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/OrthogonalEdgeRouter.cs b/src/DemaConsulting.Rendering.Layout/Engine/OrthogonalEdgeRouter.cs new file mode 100644 index 0000000..8bc3c2c --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/OrthogonalEdgeRouter.cs @@ -0,0 +1,588 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine; + +/// +/// The outcome of a routing request: the computed waypoints and whether the route had to cross an +/// obstacle (i.e. no obstacle-free orthogonal path could be found). +/// +/// Ordered orthogonal waypoints from source to target. +/// +/// when the router fell back to a path that may cross a box; this indicates a +/// degenerate (over-dense or overlapping) placement worth surfacing as a layout warning. +/// +internal readonly record struct RouteResult(IReadOnlyList Waypoints, bool Crossed); + +/// +/// A cost band: a region of the canvas where routing is cheaper (or dearer) than normal, used to bias +/// the router toward bundling wires along committed highways. +/// +/// True when the band spans a horizontal stripe (Y range), false for vertical (X range). +/// Lower bound of the band on its perpendicular axis, in logical pixels. +/// Upper bound of the band on its perpendicular axis, in logical pixels. +/// Cost factor applied to segments inside the band (0.6 cheaper, 1.0 neutral). +internal readonly record struct CostBand(bool IsHorizontal, double Start, double End, double Multiplier); + +/// +/// Routes orthogonal (right-angle) connector lines between two points while avoiding a set of +/// rectangular obstacles. +/// +/// +/// +/// The router builds a sparse "Hanan-style" routing grid whose vertical lines are the source and +/// target X coordinates plus each obstacle's left/right edges offset outward by a clearance, and +/// whose horizontal lines are the analogous Y coordinates. It then runs an A* search over the grid, +/// preferring straight runs via a turn penalty. Because the grid lines include the exact source and +/// target coordinates, the returned path starts at the source and ends at the target exactly, and +/// every segment is strictly horizontal or vertical. +/// +/// +/// The caller must exclude the boxes that own the source and target anchors from +/// obstacles; otherwise the very first segment would be blocked by the source's own box. +/// When no obstacle-free path exists, the router falls back to a simple L-shaped route so that a +/// result is always returned. +/// +/// +internal static class OrthogonalEdgeRouter +{ + /// Direction of travel along a segment, used for turn-penalty accounting. + private enum Dir + { + /// No prior direction (search start). + None, + + /// Horizontal travel. + Horizontal, + + /// Vertical travel. + Vertical, + } + + /// + /// Computes an orthogonal route from to + /// avoiding the interiors of the given obstacles. + /// + /// Start point (typically an anchor on the source box boundary). + /// End point (typically an anchor on the target box boundary). + /// + /// Rectangles to route around, excluding the boxes that own the source and target anchors. + /// + /// Minimum gap kept between routed segments and obstacles. + /// + /// Optional box side the source anchor sits on. When given, the route leaves the source with a + /// short stub perpendicular to that side before routing freely, so connectors exit boxes cleanly. + /// + /// Optional box side the target anchor sits on; see . + /// Optional cost bands biasing the route toward highway corridors; null leaves cost neutral. + /// + /// An ordered list of waypoints beginning with and ending with + /// . Consecutive waypoints always share an X or a Y coordinate. + /// + public static IReadOnlyList Route( + Point2D source, + Point2D target, + IReadOnlyList obstacles, + double clearance, + PortSide? sourceSide = null, + PortSide? targetSide = null, + IReadOnlyList? costBands = null) => + RouteWithStatus(source, target, obstacles, clearance, sourceSide, targetSide, costBands).Waypoints; + + /// + /// Computes an orthogonal route and reports whether it had to cross an obstacle. The route is + /// attempted with progressively smaller clearances; only when no obstacle-free orthogonal path + /// exists at any clearance does it fall back to a (possibly crossing) L-shape, in which case + /// is . + /// + /// Start point (typically an anchor on the source box boundary). + /// End point (typically an anchor on the target box boundary). + /// Rectangles to route around, excluding the source and target boxes. + /// Preferred gap between routed segments and obstacles. + /// Optional box side the source anchor sits on (adds a perpendicular stub). + /// Optional box side the target anchor sits on (adds a perpendicular stub). + /// Optional cost bands biasing the route toward highway corridors; null leaves cost neutral. + /// The waypoints and a flag indicating whether the route crosses an obstacle. + public static RouteResult RouteWithStatus( + Point2D source, + Point2D target, + IReadOnlyList obstacles, + double clearance, + PortSide? sourceSide = null, + PortSide? targetSide = null, + IReadOnlyList? costBands = null) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(obstacles); + + // Step off each anchor's box edge with a perpendicular stub so connectors enter and leave + // boxes at right angles instead of sliding along the edge. The stub is capped so that two + // stubs facing each other across a small gap meet at the midline instead of overshooting + // (which would force a back-and-forth jog right at the arrowhead). + // FUTURE (Phase 14c cleanup): replace this magic 8.0 with theme.ConnectorApproachZone. + var stub = clearance + 8.0; + var routeSource = StepOff(source, sourceSide, StubLength(source, sourceSide, target, stub)); + var routeTarget = StepOff(target, targetSide, StubLength(target, targetSide, source, stub)); + + // Try to find an obstacle-free orthogonal path, preferring the largest clearance that works. + foreach (var c in ClearanceLevels(clearance)) + { + var xs = BuildAxis(routeSource.X, routeTarget.X, obstacles, c, horizontal: true); + var ys = BuildAxis(routeSource.Y, routeTarget.Y, obstacles, c, horizontal: false); + + var path = AStar( + xs, ys, + IndexOf(xs, routeSource.X), IndexOf(ys, routeSource.Y), + IndexOf(xs, routeTarget.X), IndexOf(ys, routeTarget.Y), + obstacles, c, costBands); + + if (path is not null) + { + return new RouteResult(Finalize(source, target, sourceSide, targetSide, path), Crossed: false); + } + } + + // No clean path at any clearance: fall back to the least-bad L-shape (it may cross a box). + var fallback = BuildObstacleAwareFallback(routeSource, routeTarget, obstacles); + return new RouteResult(Finalize(source, target, sourceSide, targetSide, fallback), Crossed: true); + } + + /// + /// Yields the clearances to attempt, from the requested value down to zero, so the router prefers + /// a spacious route but still hugs box edges (clearance 0) rather than crossing them. + /// + private static IEnumerable ClearanceLevels(double clearance) + { + var seen = new HashSet(); + foreach (var c in new[] { clearance, clearance / 2.0, clearance / 4.0, 0.0 }) + { + var v = Math.Max(0.0, c); + if (seen.Add(v)) + { + yield return v; + } + } + } + + /// + /// Re-attaches the original anchor points outside their stubs and simplifies the path. + /// + private static IReadOnlyList Finalize( + Point2D source, + Point2D target, + PortSide? sourceSide, + PortSide? targetSide, + IReadOnlyList path) + { + var full = new List(); + if (sourceSide is not null) + { + full.Add(source); + } + + full.AddRange(path); + + if (targetSide is not null) + { + full.Add(target); + } + + return Simplify(full); + } + + /// + /// Returns the point offset from by in the + /// outward-normal direction of , or the anchor unchanged when no side. + /// + private static Point2D StepOff(Point2D anchor, PortSide? side, double distance) => side switch + { + PortSide.Top => new Point2D(anchor.X, anchor.Y - distance), + PortSide.Bottom => new Point2D(anchor.X, anchor.Y + distance), + PortSide.Left => new Point2D(anchor.X - distance, anchor.Y), + PortSide.Right => new Point2D(anchor.X + distance, anchor.Y), + _ => anchor, + }; + + /// + /// Returns the stub length to step off 's edge: the base length, but + /// capped to half the distance to measured along the side's outward + /// normal when lies in that direction. This keeps two stubs that face + /// each other across a narrow gap from overshooting past the midline (which produces a visible + /// reversal at the connector's end). When the projection is exactly zero (ports at the same + /// coordinate on facing sides, e.g. touching-edge boxes), the stub collapses to zero so the + /// connector routes directly between the ports without stepping into the opposite box. + /// + private static double StubLength(Point2D anchor, PortSide? side, Point2D other, double baseStub) + { + var projection = side switch + { + PortSide.Top => anchor.Y - other.Y, + PortSide.Bottom => other.Y - anchor.Y, + PortSide.Left => anchor.X - other.X, + PortSide.Right => other.X - anchor.X, + _ => double.PositiveInfinity, + }; + + return projection >= 0 ? Math.Min(baseStub, projection / 2.0) : baseStub; + } + + /// + /// Builds the sorted, de-duplicated set of grid coordinates for one axis: the two endpoint + /// coordinates plus each obstacle's near/far edge offset outward by the clearance. + /// + private static double[] BuildAxis( + double a, + double b, + IReadOnlyList obstacles, + double clearance, + bool horizontal) + { + var set = new SortedSet { a, b }; + foreach (var r in obstacles) + { + if (horizontal) + { + set.Add(r.X - clearance); + set.Add(r.X + r.Width + clearance); + } + else + { + set.Add(r.Y - clearance); + set.Add(r.Y + r.Height + clearance); + } + } + + return [.. set]; + } + + /// + /// Returns the index of the grid line equal to . The value is always + /// present because the axis was built to include it. + /// + private static int IndexOf(double[] axis, double value) + { + for (var i = 0; i < axis.Length; i++) + { + if (Math.Abs(axis[i] - value) < 1e-9) + { + return i; + } + } + + // Should never happen: endpoint coordinates are always added to the axis. + return 0; + } + + /// + /// Runs an A* search over the grid, returning the sequence of grid points from start to goal, + /// or when no obstacle-free path exists. + /// + private static List? AStar( + double[] xs, + double[] ys, + int startI, + int startJ, + int goalI, + int goalJ, + IReadOnlyList obstacles, + double clearance, + IReadOnlyList? costBands) + { + var nx = xs.Length; + var ny = ys.Length; + + // Visited cost keyed by (i, j, direction) so straight-through and turning arrivals differ. + var best = new Dictionary<(int, int, Dir), double>(); + var cameFrom = new Dictionary<(int, int, Dir), (int, int, Dir)>(); + var open = new PriorityQueue<(int I, int J, Dir D), double>(); + + var startState = (startI, startJ, Dir.None); + best[startState] = 0.0; + open.Enqueue((startI, startJ, Dir.None), Heuristic(xs, ys, startI, startJ, goalI, goalJ)); + + // Turn penalty expressed in pixels; comparable to a short straight run so detours that + // remove a bend are preferred only when not much longer. + const double TurnPenalty = 20.0; + + while (open.Count > 0) + { + var (ci, cj, cd) = open.Dequeue(); + var current = (ci, cj, cd); + var g = best[current]; + + if (ci == goalI && cj == goalJ) + { + return Reconstruct(xs, ys, cameFrom, current); + } + + foreach (var (ni, nj, nd) in Neighbors(ci, cj, nx, ny)) + { + // Skip moves whose segment passes within the clearance of an obstacle. + if (SegmentBlocked(xs, ys, ci, cj, ni, nj, obstacles, clearance)) + { + continue; + } + + var stepLength = nd == Dir.Horizontal + ? Math.Abs(xs[ni] - xs[ci]) + : Math.Abs(ys[nj] - ys[cj]); + var bandMultiplier = SegmentCostMultiplier(xs, ys, ci, cj, ni, nj, costBands); + var turnCost = cd != Dir.None && cd != nd ? TurnPenalty : 0.0; + var tentative = g + (stepLength * bandMultiplier) + turnCost; + + var neighborState = (ni, nj, nd); + if (best.TryGetValue(neighborState, out var existing) && tentative >= existing) + { + continue; + } + + best[neighborState] = tentative; + cameFrom[neighborState] = current; + var f = tentative + Heuristic(xs, ys, ni, nj, goalI, goalJ); + open.Enqueue(neighborState, f); + } + } + + return null; + } + + /// Enumerates the four grid neighbors of a node along with the travel direction. + private static IEnumerable<(int I, int J, Dir D)> Neighbors(int i, int j, int nx, int ny) + { + if (i + 1 < nx) + { + yield return (i + 1, j, Dir.Horizontal); + } + + if (i - 1 >= 0) + { + yield return (i - 1, j, Dir.Horizontal); + } + + if (j + 1 < ny) + { + yield return (i, j + 1, Dir.Vertical); + } + + if (j - 1 >= 0) + { + yield return (i, j - 1, Dir.Vertical); + } + } + + /// Manhattan-distance heuristic between two grid nodes. + private static double Heuristic(double[] xs, double[] ys, int i, int j, int goalI, int goalJ) => + Math.Abs(xs[i] - xs[goalI]) + Math.Abs(ys[j] - ys[goalJ]); + + /// + /// Returns the cheapest cost multiplier for a segment by testing whether its midpoint lies inside + /// any cost band; a highway band (multiplier < 1) makes bundled runs cheaper so the router + /// prefers them. When no band applies the cost is neutral (1.0). + /// + private static double SegmentCostMultiplier( + double[] xs, + double[] ys, + int i1, + int j1, + int i2, + int j2, + IReadOnlyList? bands) + { + if (bands is null || bands.Count == 0) + { + return 1.0; + } + + // Sample the segment midpoint; bands are stripes, so a point test is enough to classify it. + var mx = (xs[i1] + xs[i2]) / 2.0; + var my = (ys[j1] + ys[j2]) / 2.0; + var best = 1.0; + foreach (var band in bands) + { + var coord = band.IsHorizontal ? my : mx; + if (coord >= band.Start && coord <= band.End) + { + best = Math.Min(best, band.Multiplier); + } + } + + return best; + } + + /// + /// Determines whether the straight grid segment between two adjacent nodes passes within + /// of any obstacle (the obstacle rectangles are inflated by the + /// clearance and tested with strict inequalities, so a segment exactly one clearance away is + /// allowed). + /// + private static bool SegmentBlocked( + double[] xs, + double[] ys, + int i1, + int j1, + int i2, + int j2, + IReadOnlyList obstacles, + double clearance) + { + if (j1 == j2) + { + // Horizontal segment at y = ys[j1] spanning the two x grid lines. + var y = ys[j1]; + var xa = Math.Min(xs[i1], xs[i2]); + var xb = Math.Max(xs[i1], xs[i2]); + foreach (var r in obstacles) + { + if (r.Y - clearance < y && y < r.Y + r.Height + clearance && + Math.Max(xa, r.X - clearance) < Math.Min(xb, r.X + r.Width + clearance)) + { + return true; + } + } + } + else + { + // Vertical segment at x = xs[i1] spanning the two y grid lines. + var x = xs[i1]; + var ya = Math.Min(ys[j1], ys[j2]); + var yb = Math.Max(ys[j1], ys[j2]); + foreach (var r in obstacles) + { + if (r.X - clearance < x && x < r.X + r.Width + clearance && + Math.Max(ya, r.Y - clearance) < Math.Min(yb, r.Y + r.Height + clearance)) + { + return true; + } + } + } + + return false; + } + + /// Reconstructs the grid-point path by walking the came-from chain back to the start. + private static List Reconstruct( + double[] xs, + double[] ys, + Dictionary<(int, int, Dir), (int, int, Dir)> cameFrom, + (int, int, Dir) goal) + { + var points = new List(); + var cursor = goal; + while (true) + { + var (i, j, _) = cursor; + points.Add(new Point2D(xs[i], ys[j])); + if (!cameFrom.TryGetValue(cursor, out var prev)) + { + break; + } + + cursor = prev; + } + + points.Reverse(); + return points; + } + + /// Collapses consecutive collinear waypoints into single straight segments. + private static IReadOnlyList Simplify(List points) + { + if (points.Count <= 2) + { + return points; + } + + var result = new List { points[0] }; + for (var k = 1; k < points.Count - 1; k++) + { + var prev = result[^1]; + var cur = points[k]; + var next = points[k + 1]; + + // Drop exact duplicates of the previous point (stubs can introduce these). + if (Math.Abs(prev.X - cur.X) < 1e-9 && Math.Abs(prev.Y - cur.Y) < 1e-9) + { + continue; + } + + // Drop the middle point only when prev→cur→next is collinear AND monotonic (same + // direction). A direction reversal (U-turn) on the same axis must be preserved, e.g. a + // perpendicular stub that briefly overshoots before entering a box. + var collinearX = Math.Abs(prev.X - cur.X) < 1e-9 && Math.Abs(cur.X - next.X) < 1e-9 && + (cur.Y - prev.Y) * (next.Y - cur.Y) >= 0; + var collinearY = Math.Abs(prev.Y - cur.Y) < 1e-9 && Math.Abs(cur.Y - next.Y) < 1e-9 && + (cur.X - prev.X) * (next.X - cur.X) >= 0; + if (!collinearX && !collinearY) + { + result.Add(cur); + } + } + + // Append the final point unless it duplicates the current last point. + if (Math.Abs(result[^1].X - points[^1].X) >= 1e-9 || Math.Abs(result[^1].Y - points[^1].Y) >= 1e-9) + { + result.Add(points[^1]); + } + + return result; + } + + /// + /// Builds the least-bad L-shaped fallback route used when A* cannot find an obstacle-free path: + /// it tries the horizontal-first and vertical-first elbows and returns whichever crosses fewer + /// obstacles. + /// + private static IReadOnlyList BuildObstacleAwareFallback( + Point2D source, + Point2D target, + IReadOnlyList obstacles) + { + // Aligned endpoints need only a straight segment. + if (Math.Abs(source.X - target.X) < 1e-9 || Math.Abs(source.Y - target.Y) < 1e-9) + { + return [source, target]; + } + + // Two candidate elbows: horizontal-first and vertical-first. + var horizontalFirst = new List { source, new(target.X, source.Y), target }; + var verticalFirst = new List { source, new(source.X, target.Y), target }; + + var hCrossings = CountCrossings(horizontalFirst, obstacles); + var vCrossings = CountCrossings(verticalFirst, obstacles); + + return hCrossings <= vCrossings ? horizontalFirst : verticalFirst; + } + + /// Counts how many (segment, obstacle) pairs along a path cross an obstacle interior. + private static int CountCrossings(IReadOnlyList path, IReadOnlyList obstacles) + { + var count = 0; + for (var i = 0; i < path.Count - 1; i++) + { + var a = path[i]; + var b = path[i + 1]; + count += obstacles.Count(r => SegmentCrossesRect(a, b, r)); + } + + return count; + } + + /// Returns true when an axis-aligned segment passes through a rectangle's strict interior. + private static bool SegmentCrossesRect(Point2D a, Point2D b, Rect r) + { + if (Math.Abs(a.Y - b.Y) < 1e-9) + { + var y = a.Y; + var xa = Math.Min(a.X, b.X); + var xb = Math.Max(a.X, b.X); + return r.Y < y && y < r.Y + r.Height && Math.Max(xa, r.X) < Math.Min(xb, r.X + r.Width); + } + + var x = a.X; + var ya = Math.Min(a.Y, b.Y); + var yb = Math.Max(a.Y, b.Y); + return r.X < x && x < r.X + r.Width && Math.Max(ya, r.Y) < Math.Min(yb, r.Y + r.Height); + } +} diff --git a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs new file mode 100644 index 0000000..d710596 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs @@ -0,0 +1,484 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Layout; + +/// +/// The bundled recursive hierarchical layout engine: it lays out a compound +/// by placing each container node's children in their own coordinate space, sizing the container to +/// fit them, and composing the placed sub-layouts into a single absolute . +/// This is the ELK RecursiveGraphLayoutEngine analogue; it selects a bundled leaf algorithm +/// (for example layered or containment) per scope and delegates the actual box placement +/// to it. +/// +/// +/// +/// The engine walks the graph in post-order: each container's children are laid out first, +/// then the container is given an effective size that encloses that sub-layout (plus padding and an +/// optional title band), and finally the current level is placed by the selected leaf algorithm +/// using those effective sizes. The placed sub-layouts are translated from their local origin into +/// each container box's interior and attached as the box's . +/// +/// +/// The leaf algorithm is chosen per scope through : the +/// root scope inherits the algorithm from the supplied (default +/// layered), and any container node may override it by setting +/// on itself; unset containers inherit their parent scope's +/// algorithm. This lets, for example, a containment-packed root hold a layered +/// container without either level knowing about the other. +/// +/// +/// Hierarchy is handled in mode: each container is +/// laid out in isolation and sized to fit its children, so container placements are independent and +/// deterministic. Cross-container edges — edges whose endpoints live in different descendant +/// containers, added to their lowest common ancestor per the model's LCA convention — are routed at +/// the level that owns them, steering around the sibling containers between the two endpoints. +/// +/// +/// Flat-graph equivalence guarantee. When no direct node of a scope is a container +/// (a flat graph), the engine returns the selected leaf algorithm's output unchanged: it +/// delegates directly to the algorithm without cloning the graph or post-processing the tree, so the +/// result is byte-for-byte identical to invoking that algorithm itself. Hierarchical composition +/// only engages once a scope actually contains a container node. +/// +/// +/// The engine never mutates the caller's input graph. When a level must be re-sized to account for +/// nested content, it builds an internal sized view graph (same node order, container nodes +/// carrying their effective size, in-scope edges only) and lays that out, leaving the caller's graph +/// untouched. The engine is stateless and its is safe to call concurrently on +/// distinct graphs. +/// +/// +/// +/// +/// // A two-level graph: a labelled container laid out "layered", packed by a "containment" root. +/// var graph = new LayoutGraph(); +/// var group = graph.AddNode("group", 10, 10); +/// group.Label = "Group"; +/// group.Set(CoreOptions.Algorithm, "layered"); // this container lays its children out layered +/// var c1 = group.Children.AddNode("c1", 80, 40); +/// var c2 = group.Children.AddNode("c2", 80, 40); +/// group.Children.AddEdge("c1-c2", c1, c2); // intra-container edge +/// graph.AddNode("outside", 80, 40); // a sibling leaf at the root +/// +/// // Pack the root with the containment algorithm; containers recurse with their own algorithm. +/// var options = LayoutOptions.ForAlgorithm("containment"); +/// var tree = new HierarchicalLayoutAlgorithm().Apply(graph, options); +/// +/// // Hand the composed tree to a renderer (for example the SVG renderer). +/// // new SvgRenderer().Render(tree, new RenderOptions(Themes.Light), stream); +/// +/// +public sealed class HierarchicalLayoutAlgorithm : ILayoutAlgorithm +{ + /// + /// The stable algorithm identifier "hierarchical" under which this algorithm is selected and + /// registered. Pass it to or + /// instead of hardcoding the literal string. + /// + public const string AlgorithmId = "hierarchical"; + + /// + /// The leaf algorithm a scope is placed with when it resolves to this engine's own + /// . Because this engine is not a leaf and is never registered into the leaf + /// registry, selecting "hierarchical" for a scope (including through the facade default) means + /// "apply the hierarchical engine, placing this level with the default leaf algorithm" rather than a + /// self-referential lookup that cannot resolve. That default leaf is . + /// + private const string DefaultLeafAlgorithmId = LayeredLayoutAlgorithm.AlgorithmId; + + /// + /// Inset, in logical pixels, kept on every side between a container's border and the sub-layout of + /// its children. Sizing each container to its children plus this padding keeps nested content from + /// touching the container edge. + /// + private const double ContainerPadding = 12.0; + + /// + /// Height, in logical pixels, of the title band reserved at the top of a container that carries a + /// . The children are offset below this band so the label and the + /// nested content never overlap; a container with no label reserves no band. + /// + private const double ContainerTitleHeight = 24.0; + + /// + /// The leaf-algorithm provider used to resolve the per-scope layout algorithm by identifier. It is + /// deliberately limited to leaf algorithms (for example layered and containment); this + /// engine is not registered into it, so recursion always terminates in a bundled leaf algorithm. + /// + private readonly LayoutAlgorithmRegistry _registry; + + /// + /// Initializes a new instance of the class backed by a + /// default registry of the bundled leaf algorithms ( and + /// ). + /// + /// + /// This convenience constructor lets callers use the engine without assembling a registry. It + /// registers only the leaf algorithms, never this engine itself, so a scope that selects an + /// unregistered algorithm surfaces a resolution error rather than recursing indefinitely. + /// + public HierarchicalLayoutAlgorithm() + : this(new LayoutAlgorithmRegistry() + .Register(new LayeredLayoutAlgorithm()) + .Register(new ContainmentLayoutAlgorithm())) + { + } + + /// + /// Initializes a new instance of the class that resolves + /// per-scope leaf algorithms from the supplied . + /// + /// + /// Provider of the leaf algorithms this engine delegates to. It should contain only leaf algorithms + /// (not this hierarchical engine) so recursion terminates; every algorithm identifier referenced by + /// the graph or options must be resolvable from it. + /// + /// Thrown when is . + public HierarchicalLayoutAlgorithm(LayoutAlgorithmRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _registry = registry; + } + + /// + public string Id => AlgorithmId; + + /// + /// + /// Thrown when or is . + /// + /// + /// Thrown when a scope selects a layout algorithm identifier that is not registered. + /// + public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(options); + + // The root scope inherits the algorithm from the options (its own default is "layered"), unless + // the graph itself carries an explicit CoreOptions.Algorithm override. + var algoId = ResolveScopeAlgorithm(graph, options.Get(CoreOptions.Algorithm)); + return LayoutScope(graph, algoId, options); + } + + /// + /// Lays out a single container scope with the resolved leaf algorithm, recursing into any nested + /// containers first and composing their sub-layouts into this level's coordinate space. + /// + /// The container scope to place (the root graph or a node's child subgraph). + /// Identifier of the leaf algorithm to place this scope with. + /// The shared options carried into every leaf-algorithm invocation. + /// The placed sub-tree for this scope, in local coordinates rooted at the origin. + private LayoutTree LayoutScope(LayoutGraph graph, string algoId, LayoutOptions options) + { + var algo = _registry.Resolve(algoId); + + // Flat fast path: when no direct node is a container, delegate straight to the leaf algorithm so + // the result is byte-for-byte identical to invoking that algorithm directly. This preserves the + // flat-graph equivalence guarantee: no cloning, no post-processing, no mutation. + var anyContainer = false; + foreach (var node in graph.Nodes) + { + if (node.HasChildren) + { + anyContainer = true; + break; + } + } + + if (!anyContainer) + { + return algo.Apply(graph, options); + } + + // Hierarchical path (SeparateChildren): recurse into each container child, size it to fit, place + // this level over a sized view, compose the sub-layouts, and route cross-container edges. + var subLayouts = new Dictionary(); + var effectiveSize = new Dictionary(); + LayoutContainerChildren(graph, algoId, options, subLayouts, effectiveSize); + + var (view, _) = BuildSizedView(graph, effectiveSize); + + // Place this level with the resolved leaf algorithm over the sized view. The leaf algorithm + // emits one box per node in Nodes order, so placed boxes align with graph.Nodes by index. + var placed = algo.Apply(view, options); + var placedBoxes = placed.Nodes.OfType().ToList(); + var placedLines = placed.Nodes.OfType().ToList(); + + var (composed, indexOf) = ComposeBoxes(graph, placedBoxes, subLayouts); + + var crossLines = RouteCrossContainerEdges(graph, composed, indexOf, options); + + // Assemble: composed boxes, then the leaf algorithm's routed lines, then the cross-container + // lines. The canvas dimensions are the leaf algorithm's for this (sized) level. + var nodes = new List(composed.Length + placedLines.Count + crossLines.Count); + nodes.AddRange(composed); + nodes.AddRange(placedLines); + nodes.AddRange(crossLines); + return new LayoutTree(placed.Width, placed.Height, nodes); + } + + /// + /// Recurses into every container child of , laying out its subgraph and + /// recording both the resulting sub-layout and the container's effective (child-fitting) size. + /// + /// The scope whose container children are laid out. + /// The algorithm inherited by children that do not override it. + /// The shared options carried into each recursive layout. + /// Receives each container's placed sub-tree in local coordinates. + /// Receives each container's size enclosing its children plus padding/title. + private void LayoutContainerChildren( + LayoutGraph graph, + string algoId, + LayoutOptions options, + Dictionary subLayouts, + Dictionary effectiveSize) + { + foreach (var node in graph.Nodes) + { + if (!node.HasChildren) + { + continue; + } + + // A container inherits this scope's algorithm unless it overrides CoreOptions.Algorithm. + var childAlgoId = ResolveScopeAlgorithm(node, algoId); + var sub = LayoutScope(node.Children, childAlgoId, options); + subLayouts[node] = sub; + + // Size the container to enclose its sub-layout plus a padding inset on every side and, when + // the container is labelled, a title band above the children. + var titleHeight = node.Label is null ? 0.0 : ContainerTitleHeight; + effectiveSize[node] = ( + sub.Width + (2 * ContainerPadding), + sub.Height + (2 * ContainerPadding) + titleHeight); + } + } + + /// + /// Builds an internal, side-effect-free sized view of : the same nodes in + /// the same order (container nodes carrying their effective size, leaves their own size) and only + /// the edges whose endpoints are both direct members of this scope. + /// + /// The caller's scope, which is never mutated. + /// Effective sizes for the container nodes of this scope. + /// The sized view graph and a map from each original node to its view counterpart. + private static (LayoutGraph View, Dictionary ViewOf) BuildSizedView( + LayoutGraph graph, + Dictionary effectiveSize) + { + var view = new LayoutGraph(); + var viewOf = new Dictionary(graph.Nodes.Count); + foreach (var node in graph.Nodes) + { + var (width, height) = effectiveSize.TryGetValue(node, out var size) + ? size + : (node.Width, node.Height); + var viewNode = view.AddNode(node.Id, width, height); + viewNode.Label = node.Label; + viewOf[node] = viewNode; + } + + foreach (var edge in graph.Edges) + { + // Only edges whose both endpoints are direct members of this scope are placed by the leaf + // algorithm here; cross-container edges are routed separately after composition. + if (viewOf.TryGetValue(edge.Source, out var viewSource) && + viewOf.TryGetValue(edge.Target, out var viewTarget)) + { + var viewEdge = view.AddEdge(edge.Id, viewSource, viewTarget); + viewEdge.TargetEnd = edge.TargetEnd; + viewEdge.LineStyle = edge.LineStyle; + viewEdge.Label = edge.Label; + } + } + + return (view, viewOf); + } + + /// + /// Composes the placed boxes for this level: each container box receives its recursively laid-out + /// children, translated from their local origin into the box's padded (and, when labelled, + /// title-offset) interior. + /// + /// The scope whose nodes align with by index. + /// The leaf algorithm's placed boxes, one per node in input order. + /// The recursively laid-out sub-tree for each container node. + /// The composed boxes and a map from each node to its positional index. + private static (LayoutBox[] Composed, Dictionary IndexOf) ComposeBoxes( + LayoutGraph graph, + List placedBoxes, + Dictionary subLayouts) + { + var count = graph.Nodes.Count; + var indexOf = new Dictionary(count); + var composed = new LayoutBox[count]; + for (var i = 0; i < count; i++) + { + var node = graph.Nodes[i]; + indexOf[node] = i; + var box = placedBoxes[i]; + + if (subLayouts.TryGetValue(node, out var sub)) + { + // Offset the nested content to the container's padded interior, below any title band. + var titleHeight = node.Label is null ? 0.0 : ContainerTitleHeight; + var offsetX = box.X + ContainerPadding; + var offsetY = box.Y + ContainerPadding + titleHeight; + var children = new List(sub.Nodes.Count); + foreach (var childNode in sub.Nodes) + { + children.Add(Translate(childNode, offsetX, offsetY)); + } + + composed[i] = box with { Children = children }; + } + else + { + composed[i] = box; + } + } + + return (composed, indexOf); + } + + /// + /// Routes the cross-container edges owned by this scope — edges whose endpoints resolve to different + /// direct-member containers of this level — around the sibling boxes between them. + /// + /// The scope whose edges are examined for cross-container routing. + /// 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 . + /// Options supplying the per-scope style. + /// One routed line per cross-container edge owned by this scope. + private static List RouteCrossContainerEdges( + LayoutGraph graph, + LayoutBox[] composed, + Dictionary indexOf, + LayoutOptions options) + { + // Map every descendant node to the direct member of this scope that contains it, so an edge that + // references a deeply nested endpoint can be anchored to the top-level box that owns it. + var descendantToDirect = new Dictionary(); + foreach (var direct in graph.Nodes) + { + MapDescendants(direct, direct, descendantToDirect); + } + + var routeOptions = new ConnectorRouteOptions(options.Get(CoreOptions.EdgeRouting)); + var boxesForRouting = (IReadOnlyList)composed; + var crossLines = new List(); + foreach (var edge in graph.Edges) + { + // Skip edges whose endpoints are not both under this scope. + if (!descendantToDirect.TryGetValue(edge.Source, out var sourceDirect) || + !descendantToDirect.TryGetValue(edge.Target, out var targetDirect)) + { + continue; + } + + // An edge whose endpoints are both direct members is already routed by the leaf algorithm. + var bothDirect = ReferenceEquals(sourceDirect, edge.Source) && + ReferenceEquals(targetDirect, edge.Target); + if (bothDirect) + { + continue; + } + + // An edge whose endpoints share one container belongs to that lower scope, not this one. + if (ReferenceEquals(sourceDirect, targetDirect)) + { + continue; + } + + var from = composed[indexOf[sourceDirect]]; + var to = composed[indexOf[targetDirect]]; + crossLines.Add(ConnectorRouter.Route( + boxesForRouting, + new Connection(from, to, edge.TargetEnd, edge.LineStyle, edge.Label), + routeOptions)); + } + + return crossLines; + } + + /// + /// Resolves the layout algorithm for a scope: the scope's explicit + /// override when present, otherwise the algorithm from the parent scope. + /// + /// The graph or node whose algorithm override is consulted. + /// The algorithm inherited when the scope carries no override. + /// + /// The leaf-algorithm identifier to place the scope with. When the scope resolves to this engine's own + /// (which is not a leaf), the is + /// substituted so recursion terminates in a registered leaf instead of failing to resolve. + /// + private static string ResolveScopeAlgorithm(PropertyHolder scope, string inherited) + { + var resolved = scope.TryGet(CoreOptions.Algorithm, out var value) ? value : inherited; + return resolved == AlgorithmId ? DefaultLeafAlgorithmId : resolved; + } + + /// + /// Records that and all of its descendants belong to the direct-member + /// container , so a cross-container edge referencing any descendant can be + /// anchored to the top-level box representing that container. + /// + /// The node currently being mapped (initially the direct member itself). + /// The direct member of the scope that owns this subtree. + /// The descendant-to-direct-member map being populated. + private static void MapDescendants( + LayoutGraphNode node, + LayoutGraphNode direct, + Dictionary map) + { + map[node] = direct; + if (node.HasChildren) + { + foreach (var child in node.Children.Nodes) + { + MapDescendants(child, direct, map); + } + } + } + + /// + /// Translates a placed layout node by a fixed offset, recursively shifting a box's nested children + /// and a line's waypoints so a locally-placed sub-layout can be composed into an absolute position. + /// + /// The placed node to translate; only boxes and lines are produced by the bundled leaf algorithms. + /// Offset added to every X coordinate, in logical pixels. + /// Offset added to every Y coordinate, in logical pixels. + /// A translated copy of , or the node unchanged for unknown subtypes. + private static LayoutNode Translate(LayoutNode node, double deltaX, double deltaY) + { + switch (node) + { + case LayoutBox box: + var children = new List(box.Children.Count); + foreach (var child in box.Children) + { + children.Add(Translate(child, deltaX, deltaY)); + } + + return box with { X = box.X + deltaX, Y = box.Y + deltaY, Children = children }; + + case LayoutLine line: + var waypoints = new List(line.Waypoints.Count); + foreach (var point in line.Waypoints) + { + waypoints.Add(new Point2D(point.X + deltaX, point.Y + deltaY)); + } + + return line with { Waypoints = waypoints }; + + default: + // Forward compatibility: an unknown node subtype is left untranslated rather than dropped. + return node; + } + } +} diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs new file mode 100644 index 0000000..e6eb145 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs @@ -0,0 +1,133 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout; + +/// +/// The bundled ELK-style layered layout algorithm: arranges the input graph into Sugiyama layers and +/// routes edges orthogonally, producing a placed of boxes and connectors. +/// This is the reference implementation; it wraps the reusable layered +/// pipeline under Engine/Layered/. +/// +public sealed class LayeredLayoutAlgorithm : ILayoutAlgorithm +{ + /// + /// The stable algorithm identifier "layered" under which this algorithm is selected and + /// registered. Pass it to or + /// instead of hardcoding the literal string. + /// + public const string AlgorithmId = "layered"; + + /// + public string Id => AlgorithmId; + + /// + public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(options); + + var graphNodes = graph.Nodes; + var count = graphNodes.Count; + + // Map each input node to a positional index the layered engine works in terms of. + var indexOf = new Dictionary(count); + var engineNodes = new LayerNode[count]; + for (var i = 0; i < count; i++) + { + var node = graphNodes[i]; + indexOf[node] = i; + engineNodes[i] = new LayerNode(node.Width, node.Height); + } + + // Map edges to index pairs, dropping any that reference nodes outside this graph. + var engineEdges = new List(graph.Edges.Count); + foreach (var edge in graph.Edges) + { + if (indexOf.TryGetValue(edge.Source, out var s) && + indexOf.TryGetValue(edge.Target, out var t)) + { + engineEdges.Add(new LayerEdge(s, t)); + } + } + + var result = InterconnectionLayoutEngine.Place(engineNodes, engineEdges); + + var nodes = new List(count + graph.Edges.Count); + + // Emit one placed box per input node, preserving input order. + for (var i = 0; i < count; i++) + { + var rect = result.Rects[i]; + nodes.Add(new LayoutBox( + rect.X, + rect.Y, + rect.Width, + rect.Height, + graphNodes[i].Label, + Depth: 0, + BoxShape.Rectangle, + Compartments: [], + Children: [])); + } + + // Build a (source, target) -> polyline lookup from the acyclic edge set the engine routed. + var routes = new Dictionary<(int Source, int Target), IReadOnlyList>(); + for (var k = 0; k < result.AcyclicEdges.Count; k++) + { + var acyclic = result.AcyclicEdges[k]; + routes[(acyclic.Source, acyclic.Target)] = result.ConnectorWaypoints[k]; + } + + // Emit one connector per input edge, recovering its route (reversing a reversed back edge). + foreach (var edge in graph.Edges) + { + if (!indexOf.TryGetValue(edge.Source, out var s) || + !indexOf.TryGetValue(edge.Target, out var t)) + { + continue; + } + + var waypoints = ResolveRoute(routes, s, t, result.Rects); + nodes.Add(new LayoutLine( + waypoints, + EndMarkerStyle.None, + edge.TargetEnd, + edge.LineStyle, + edge.Label)); + } + + return new LayoutTree(result.TotalWidth, result.TotalHeight, nodes); + } + + private static IReadOnlyList ResolveRoute( + Dictionary<(int Source, int Target), IReadOnlyList> routes, + int source, + int target, + IReadOnlyList rects) + { + if (routes.TryGetValue((source, target), out var forward)) + { + return forward; + } + + // The cycle-breaking stage may have reversed this edge; recover it by reversing the polyline. + if (routes.TryGetValue((target, source), out var reversed)) + { + var flipped = new List(reversed); + flipped.Reverse(); + return flipped; + } + + // Self-loops and duplicate edges are dropped by the engine; fall back to a straight segment + // between the two node centres so the connector is still drawn. + return [Centre(rects[source]), Centre(rects[target])]; + } + + private static Point2D Centre(Rect rect) => + new(rect.X + (rect.Width / 2.0), rect.Y + (rect.Height / 2.0)); +} diff --git a/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs b/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs new file mode 100644 index 0000000..a0434d1 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/LayoutAlgorithms.cs @@ -0,0 +1,59 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Layout; + +/// +/// Convenience factory for a pre-populated with the bundled +/// layout algorithms. This is the "batteries-included" entry point that saves callers from wiring the +/// registry by hand: a single call returns a registry from which every bundled algorithm resolves by +/// its identifier. +/// +/// +/// +/// The factory lives in the DemaConsulting.Rendering.Layout package rather than in +/// Rendering.Abstractions because it references the concrete bundled algorithms. The +/// type itself belongs to Abstractions (which knows nothing of +/// any particular algorithm); this factory populates that registry from the Layout package, keeping +/// the dependency direction intact (model <- Abstractions <- Layout). +/// +/// +/// The returned registry contains the three 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. +/// +/// +/// +/// +/// // Assemble the bundled algorithms once, then resolve by identifier at run time. +/// var registry = LayoutAlgorithms.CreateDefaultRegistry(); +/// var algorithm = registry.Resolve("containment"); +/// var tree = algorithm.Apply(graph, new LayoutOptions()); +/// +/// // The registry is a normal registry: extend it with a custom algorithm if needed. +/// registry.Register(new MyTreeLayoutAlgorithm()); +/// +/// +public static class LayoutAlgorithms +{ + /// + /// Creates a populated with the three bundled layout + /// algorithms — (layered), + /// (containment), and + /// (hierarchical). + /// + /// + /// A new registry from which each bundled algorithm resolves by its . + /// The instance is freshly allocated on every call, so callers may safely add or replace algorithms. + /// + public static LayoutAlgorithmRegistry CreateDefaultRegistry() => + new LayoutAlgorithmRegistry() + .Register(new LayeredLayoutAlgorithm()) + .Register(new ContainmentLayoutAlgorithm()) + .Register(new HierarchicalLayoutAlgorithm()); +} diff --git a/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs b/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs new file mode 100644 index 0000000..b50eee6 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/LayoutEngine.cs @@ -0,0 +1,162 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Layout; + +/// +/// The batteries-included layout facade: a one-call entry point that lays out a +/// with the algorithm the graph (or options) declares, resolving it against the bundled algorithms. It +/// turns "lay out my graph with whatever algorithm it declares" into a single line, correctly handling +/// both flat and nested (compound) graphs without the caller having to assemble a registry or choose an +/// engine. +/// +/// +/// +/// Algorithm resolution. The algorithm identifier is taken, in order of +/// precedence, from an explicit set on the +/// graph itself, then from an explicit set on the +/// supplied options, and finally — when neither carries an explicit value — from +/// . The graph takes precedence because, in the ELK-style model, +/// layout options are naturally attached to the graph being laid out; the free-standing +/// acts as a fallback. The resolved identifier is looked up in the +/// registry and applied. +/// +/// +/// Why the default is hierarchical, not layered. When no algorithm is +/// declared, the facade defaults to (hierarchical) +/// rather than the layered algorithm so the single entry point correctly lays out +/// both flat and nested graphs. This is safe precisely because the hierarchical engine +/// guarantees flat-graph equivalence: for a graph with no container nodes it returns output +/// byte-for-byte identical to the selected leaf algorithm (default layered) applied directly. +/// A flat graph therefore lays out exactly as the layered algorithm would, while a nested graph is +/// composed correctly — with no decision required from the caller. Note that +/// has its own property default of layered; the facade +/// intentionally consults explicit settings only (via +/// ) so that an +/// unset graph and options fall through to instead of the +/// property default. +/// +/// +/// The parameterless overload resolves against a shared default registry of the bundled algorithms +/// (see ). Because the bundled algorithms are +/// stateless, that shared registry is safe to read concurrently. Callers that want to add or replace +/// algorithms — for example to register a custom — pass their own +/// registry to the three-argument overload. +/// +/// +/// +/// +/// using System.IO; +/// using DemaConsulting.Rendering; +/// using DemaConsulting.Rendering.Abstractions; +/// using DemaConsulting.Rendering.Layout; +/// using DemaConsulting.Rendering.Svg; +/// +/// // 1. Describe the diagram as a graph of sized boxes and directed edges. +/// 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); +/// graph.AddEdge("a-b", a, b); +/// graph.AddEdge("b-c", b, c); +/// +/// // 2. Lay it out with whatever algorithm the graph declares (default: hierarchical, +/// // which is byte-identical to layered for this flat graph). +/// var tree = LayoutEngine.Layout(graph, new LayoutOptions()); +/// +/// // 3. Render the placed tree to SVG. +/// using var output = File.Create("diagram.svg"); +/// new SvgRenderer().Render(tree, new RenderOptions(Themes.Light), output); +/// +/// +public static class LayoutEngine +{ + /// + /// Identifier of the algorithm used when neither the graph nor the options declares one: + /// hierarchical. It lays out flat and nested graphs uniformly, matching the layered algorithm + /// byte-for-byte on flat graphs while composing nested graphs correctly. + /// + public const string DefaultAlgorithmId = HierarchicalLayoutAlgorithm.AlgorithmId; + + /// + /// Shared registry of the bundled algorithms used by the parameterless overload. The bundled + /// algorithms are stateless, so a single shared instance is safe to read (resolve) concurrently. + /// + private static readonly LayoutAlgorithmRegistry DefaultRegistry = + LayoutAlgorithms.CreateDefaultRegistry(); + + /// + /// Lays out with the algorithm it declares (see the resolution rules on + /// ), resolving it from the bundled algorithms. + /// + /// The unplaced input graph, flat or nested. + /// + /// Layout options carried into the resolved algorithm. May declare + /// to select the algorithm explicitly; otherwise the graph's + /// declaration, then , applies. + /// + /// A fully-placed with absolute coordinates and routed edges. + /// + /// Thrown when or is . + /// + /// + /// Thrown when the declared algorithm identifier is not one of the bundled algorithms. + /// + public static LayoutTree Layout(LayoutGraph graph, LayoutOptions options) => + Layout(graph, options, DefaultRegistry); + + /// + /// Lays out with the algorithm it declares (see the resolution rules on + /// ), resolving it from a caller-supplied . + /// + /// The unplaced input graph, flat or nested. + /// + /// Layout options carried into the resolved algorithm. May declare + /// to select the algorithm explicitly; otherwise the graph's + /// declaration, then , applies. + /// + /// + /// Provider of the algorithms to resolve against. Use + /// as a starting point and register any custom + /// algorithms; the resolved identifier must be present in it. + /// + /// A fully-placed with absolute coordinates and routed edges. + /// + /// Thrown when , , or + /// is . + /// + /// + /// Thrown when the declared algorithm identifier is not registered in . + /// + public static LayoutTree Layout(LayoutGraph graph, LayoutOptions options, LayoutAlgorithmRegistry registry) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(registry); + + var algorithmId = ResolveAlgorithm(graph, options); + return registry.Resolve(algorithmId).Apply(graph, options); + } + + /// + /// Resolves the algorithm identifier from an explicit on the + /// graph, then on the options, falling back to when neither is set. + /// + /// The graph whose explicit algorithm declaration takes precedence. + /// The options consulted when the graph declares no algorithm. + /// The algorithm identifier to resolve and apply. + private static string ResolveAlgorithm(LayoutGraph graph, LayoutOptions options) + { + if (graph.TryGet(CoreOptions.Algorithm, out var fromGraph)) + { + return fromGraph; + } + + return options.TryGet(CoreOptions.Algorithm, out var fromOptions) + ? fromOptions + : DefaultAlgorithmId; + } +} diff --git a/src/DemaConsulting.Rendering.Layout/NamespaceDoc.cs b/src/DemaConsulting.Rendering.Layout/NamespaceDoc.cs new file mode 100644 index 0000000..7ada3bd --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/NamespaceDoc.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout; + +/// +/// The layout tier and primary entry point for turning a diagram into placed geometry: the bundled +/// layout algorithms, the one-call facade, and the connector-routing and +/// containment helpers. +/// +/// +/// +/// Given an unplaced LayoutGraph from the DemaConsulting.Rendering model, the +/// algorithms here produce a fully-placed LayoutTree ready to render. +/// +/// +/// Begin with the one-call facade : +/// LayoutEngine.Layout(graph, options) resolves whichever algorithm the graph (or options) +/// declares and lays out flat and nested graphs uniformly. The bundled algorithms are +/// (layered — ELK-style Sugiyama layering), +/// (containment — grouped/packed placement), and +/// (hierarchical — the recursive engine that +/// composes nested graphs while matching the leaf algorithm byte-for-byte on flat graphs); use +/// to build a registry of them. Supporting helpers include +/// for orthogonal edge routing among placed boxes and +/// for containment packing. Once you have a LayoutTree, +/// render it with DemaConsulting.Rendering.Svg or DemaConsulting.Rendering.Skia. +/// +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.Rendering.Skia/DemaConsulting.Rendering.Skia.csproj b/src/DemaConsulting.Rendering.Skia/DemaConsulting.Rendering.Skia.csproj new file mode 100644 index 0000000..fb1a75e --- /dev/null +++ b/src/DemaConsulting.Rendering.Skia/DemaConsulting.Rendering.Skia.csproj @@ -0,0 +1,103 @@ + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + + DemaConsulting.Rendering.Skia + 0.0.0 + DEMA Consulting + DEMA Consulting + SkiaSharp raster renderers (PNG, JPEG, and WEBP) for the DEMA Consulting rendering library, with an embedded Noto Sans font for pixel-identical output + MIT + https://github.com/demaconsulting/Rendering + https://github.com/demaconsulting/Rendering + README.md + Icon.png + rendering;png;jpeg;webp;skiasharp;dotnet-library + Copyright DEMA Consulting + Rendering SkiaSharp Renderers + Rendering + +This package embeds the Noto Sans font (SIL Open Font License 1.1, copyright Google Inc.). +SkiaSharp is used for PNG rendering (MIT license, copyright Microsoft Corporation). + + + + true + snupkg + true + true + true + + + true + true + true + true + true + latest + + + true + + + true + $(PackageId) + $(Version) + Organization: $(Company) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-Bold.ttf b/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000..713a768 Binary files /dev/null and b/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-Bold.ttf differ diff --git a/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-BoldItalic.ttf b/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-BoldItalic.ttf new file mode 100644 index 0000000..13cce27 Binary files /dev/null and b/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-BoldItalic.ttf differ diff --git a/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-Italic.ttf b/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-Italic.ttf new file mode 100644 index 0000000..67d948d Binary files /dev/null and b/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-Italic.ttf differ diff --git a/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-Regular.ttf b/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..5de3fa7 Binary files /dev/null and b/src/DemaConsulting.Rendering.Skia/Fonts/NotoSans-Regular.ttf differ diff --git a/src/DemaConsulting.Rendering.Skia/Fonts/OFL.txt b/src/DemaConsulting.Rendering.Skia/Fonts/OFL.txt new file mode 100644 index 0000000..5da64ef --- /dev/null +++ b/src/DemaConsulting.Rendering.Skia/Fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This can +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, replacing +removing, or reorganizing -- in part or in whole -- any of the components +of the Original Version, by changing formats or by porting the Font +Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/DemaConsulting.Rendering.Skia/JpegRenderer.cs b/src/DemaConsulting.Rendering.Skia/JpegRenderer.cs new file mode 100644 index 0000000..98591f8 --- /dev/null +++ b/src/DemaConsulting.Rendering.Skia/JpegRenderer.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using SkiaSharp; + +namespace DemaConsulting.Rendering.Skia; + +/// +/// Renders a to a JPEG image using SkiaSharp. JPEG is a lossy format with +/// no transparency; the renderer draws on the opaque white background shared by all raster renderers. +/// +public sealed class JpegRenderer : SkiaRasterRenderer +{ + /// + protected override SKEncodedImageFormat EncodedFormat => SKEncodedImageFormat.Jpeg; + + /// + protected override int EncodingQuality => 90; + + /// + public override string MediaType => "image/jpeg"; + + /// + public override string DefaultExtension => ".jpg"; + + /// + public override IReadOnlyList FileExtensions => [".jpg", ".jpeg"]; +} diff --git a/src/DemaConsulting.Rendering.Skia/NamespaceDoc.cs b/src/DemaConsulting.Rendering.Skia/NamespaceDoc.cs new file mode 100644 index 0000000..9d6975e --- /dev/null +++ b/src/DemaConsulting.Rendering.Skia/NamespaceDoc.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Skia; + +/// +/// The raster renderer tier: draws a placed LayoutTree (from the +/// DemaConsulting.Rendering model, laid out by DemaConsulting.Rendering.Layout) to +/// bitmap image formats (PNG, JPEG, WEBP) using SkiaSharp. +/// +/// +/// +/// is the abstract SkiaSharp rasterizer shared by the concrete +/// formats — (lossless PNG), (JPEG), and +/// (WEBP) — each implementing the +/// DemaConsulting.Rendering.Abstractions.IRenderer contract. This is the final stage of the +/// diagram pipeline for raster output; use DemaConsulting.Rendering.Svg instead for scalable +/// vector output. +/// +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.Rendering.Skia/PngRenderer.cs b/src/DemaConsulting.Rendering.Skia/PngRenderer.cs new file mode 100644 index 0000000..1bfb367 --- /dev/null +++ b/src/DemaConsulting.Rendering.Skia/PngRenderer.cs @@ -0,0 +1,42 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using SkiaSharp; + +namespace DemaConsulting.Rendering.Skia; + +/// +/// Renders a to a lossless PNG image using SkiaSharp. +/// +/// +/// Raster output requires the SkiaSharp package (a transitive dependency of this package); no +/// native handles are exposed and the caller owns the output stream. +/// +/// +/// Render a placed to a PNG file: +/// +/// using System.IO; +/// using DemaConsulting.Rendering; +/// using DemaConsulting.Rendering.Abstractions; +/// using DemaConsulting.Rendering.Skia; +/// +/// // 'tree' is a placed LayoutTree produced by a layout algorithm (see DemaConsulting.Rendering.Layout). +/// using var output = File.Create("diagram.png"); +/// new PngRenderer().Render(tree, new RenderOptions(Themes.Light), output); +/// +/// +public sealed class PngRenderer : SkiaRasterRenderer +{ + /// + protected override SKEncodedImageFormat EncodedFormat => SKEncodedImageFormat.Png; + + /// + public override string MediaType => "image/png"; + + /// + public override string DefaultExtension => ".png"; + + /// + public override IReadOnlyList FileExtensions => [".png"]; +} diff --git a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs new file mode 100644 index 0000000..512c6eb --- /dev/null +++ b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs @@ -0,0 +1,1598 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; +using SkiaSharp; + +namespace DemaConsulting.Rendering.Skia; + +/// +/// Abstract base for SkiaSharp raster renderers: draws a onto a bitmap and +/// encodes it in a concrete image format supplied by a derived renderer (PNG, JPEG, or WEBP). +/// +/// +/// The renderer is pure and stateless: each call to allocates a new +/// and , draws all nodes, and encodes the result +/// to the output stream before disposing all SkiaSharp resources. The output stream is not +/// closed or flushed by this renderer; the caller is responsible for its lifetime. +/// +/// Node rendering: +/// - → filled rectangle (plain or rounded) + optional centered label +/// + compartment dividers and text rows; children rendered recursively. +/// - → corner-radius-aware polyline built as a single +/// with optional dashing; arrowheads at both ends; optional midpoint +/// label with white background. +/// - → text element with -derived alignment. +/// - → small filled square centered at the port position with +/// optional label offset away from the attached edge. +/// - → icon shape (filled circle, bullseye, diamond, or bar) +/// centered at the badge position with optional label to the right. +/// - → swim-lane rectangle; label rendered vertically on the left +/// edge for Horizontal orientation or horizontally at the top for Vertical; children +/// rendered recursively. +/// - → header box at the top with a dashed vertical stem below. +/// - → narrow white-filled rectangle with stroke border +/// centered at CentreX. +/// - → bordered table; header rows use depth-1 fill color, body +/// rows use depth-0 fill color; per-cell text alignment respected. +/// - All other node types are silently skipped for forward compatibility. +/// +/// Fill colors are derived from using modulo wrapping on +/// . Hex color strings (e.g., #RRGGBB) are parsed with +/// . +/// +/// A minimum bitmap size of 1×1 pixels is enforced to prevent SkiaSharp allocation errors +/// when the layout tree is empty. +/// +public abstract class SkiaRasterRenderer : IRenderer +{ + /// + /// Lazily-loaded typeface for regular-weight, upright text. Loaded once from the embedded + /// NotoSans-Regular.ttf resource so all renders use the same font regardless of which fonts + /// are installed on the host system. + /// + private static readonly Lazy RegularTypeface = new(() => LoadTypeface("NotoSans-Regular.ttf")); + + /// + /// Lazily-loaded typeface for bold-weight, upright text. Loaded from NotoSans-Bold.ttf. + /// + private static readonly Lazy BoldTypeface = new(() => LoadTypeface("NotoSans-Bold.ttf")); + + /// + /// Lazily-loaded typeface for regular-weight, italic text. Loaded from NotoSans-Italic.ttf. + /// + private static readonly Lazy ItalicTypeface = new(() => LoadTypeface("NotoSans-Italic.ttf")); + + /// + /// Lazily-loaded typeface for bold-weight, italic text. Loaded from NotoSans-BoldItalic.ttf. + /// + private static readonly Lazy BoldItalicTypeface = new(() => LoadTypeface("NotoSans-BoldItalic.ttf")); + + /// + /// Loads a typeface from an embedded assembly resource. The resource is matched by its + /// filename suffix (case-insensitive). Falls back to if + /// the resource is not found, so the renderer remains functional even when the font is not + /// embedded (e.g., during development without the downloaded font files). + /// + /// File name suffix to match in the assembly manifest resource names. + /// + /// An loaded from the embedded resource, or + /// if the resource is not found. + /// + private static SKTypeface LoadTypeface(string fileName) + { + var asm = typeof(SkiaRasterRenderer).Assembly; + var resourceName = asm.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)); + if (resourceName is null) + { + return SKTypeface.Default; + } + + using var stream = asm.GetManifestResourceStream(resourceName)!; + using var data = SKData.Create(stream); + return SKTypeface.FromData(data) ?? SKTypeface.Default; + } + + /// + /// Creates an configured for text rendering with the Noto Sans typeface + /// matching the requested weight and style. The caller is responsible for disposing the + /// returned paint. + /// + /// Fill color for the text glyphs. + /// Font size in scaled pixels. + /// When , selects the bold typeface variant. + /// When , selects the italic typeface variant. + /// A new ready for use with canvas.DrawText. + private static SKPaint CreateTextPaint(SKColor color, float fontSize, bool bold, bool italic) + { + var typeface = (bold, italic) switch + { + (true, true) => BoldItalicTypeface.Value, + (true, false) => BoldTypeface.Value, + (false, true) => ItalicTypeface.Value, + _ => RegularTypeface.Value, + }; + return new SKPaint + { + Color = color, + TextSize = fontSize, + IsAntialias = true, + Typeface = typeface, + }; + } + + /// + /// Computes a reduced font size that fits within + /// scaled pixels by scaling down proportionally. + /// Returns unchanged when the text already fits or + /// when there is no meaningful width constraint. + /// + /// Paint whose is temporarily set + /// to to measure the text width. + /// Text whose rendered width is measured. + /// Maximum allowed width in scaled pixels. 0 or negative disables shrinking. + /// Preferred (maximum) font size in scaled pixels. + /// Font size in scaled pixels, guaranteed to be > 0. + private static float FitFontSize(SKPaint paint, string text, float availableWidth, float maxFontSize) + { + paint.TextSize = maxFontSize; + if (availableWidth <= 0 || string.IsNullOrEmpty(text)) + { + return maxFontSize; + } + + var measuredWidth = paint.MeasureText(text); + if (measuredWidth <= availableWidth) + { + return maxFontSize; + } + + // Scale font size proportionally so the text fits within the available width + return maxFontSize * (availableWidth / measuredWidth); + } + + /// Gets the SkiaSharp encoded-image format this renderer emits. + protected abstract SKEncodedImageFormat EncodedFormat { get; } + + /// + /// Gets the encoding quality (0-100) passed to the SkiaSharp encoder. Lossless formats such as + /// PNG ignore this value; lossy formats such as JPEG and WEBP use it. + /// + protected virtual int EncodingQuality => 100; + + /// + public abstract string MediaType { get; } + + /// + public abstract string DefaultExtension { get; } + + /// + public abstract IReadOnlyList FileExtensions { get; } + + /// + /// + /// The bitmap dimensions are derived from and + /// scaled by , + /// with a minimum of 1×1 pixels. The background is filled with the theme's + /// before any nodes are drawn. + /// + public void Render(LayoutTree layout, RenderOptions options, Stream output) + { + // Validate inputs — null arguments would produce silent failures + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(output); + + // Compute bitmap size, enforcing minimum 1×1 to prevent SKBitmap allocation errors + var w = Math.Max(1, (int)Math.Ceiling(layout.Width * options.Scale)); + var h = Math.Max(1, (int)Math.Ceiling(layout.Height * options.Scale)); + + // Allocate bitmap, canvas and render all nodes + using var bitmap = new SKBitmap(w, h, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + + // Fill the background with the theme background color before drawing diagram elements, so the + // canvas fill and the hollow-marker occlusion (which also paints in the theme background) + // share a single source of truth and stay consistent under non-white themes. + canvas.Clear(SKColor.Parse(options.Theme.BackgroundColor)); + + foreach (var node in layout.Nodes) + { + RenderNode(canvas, node, options); + } + + // Final pass: draw every connector label on top of all wires and boxes, so that no later + // wire can draw over an earlier wire's label. Positions are computed up front so that labels + // that would collide (for example where two connectors cross) are spread apart. + var lines = CollectLines(layout.Nodes).ToList(); + var labelPositions = ConnectorLabelPlacer.Place(lines, options.Theme.FontSizeBody); + foreach (var line in lines) + { + if (line.MidpointLabel is not null && labelPositions.TryGetValue(line, out var pos)) + { + RenderLineLabel(canvas, line, options, pos.X, pos.Y); + } + } + + // Encode in this renderer's concrete format and write to the output stream + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(EncodedFormat, EncodingQuality); + data.SaveTo(output); + } + + /// + /// Dispatches a single to the appropriate typed render method. + /// Unknown concrete types are silently skipped so that future node types do not break + /// existing callers. + /// + /// Canvas to draw on. + /// Node to render. + /// Render options providing theme and scale. + private static void RenderNode(SKCanvas canvas, LayoutNode node, RenderOptions options) + { + switch (node) + { + case LayoutBox box: + RenderBox(canvas, box, options); + break; + + case LayoutLine line: + RenderLine(canvas, line, options); + break; + + case LayoutLabel label: + RenderLabel(canvas, label, options); + break; + + case LayoutPort port: + RenderPort(canvas, port, options); + break; + + case LayoutBadge badge: + RenderBadge(canvas, badge, options); + break; + + case LayoutBand band: + RenderBand(canvas, band, options); + break; + + case LayoutLifeline lifeline: + RenderLifeline(canvas, lifeline, options); + break; + + case LayoutActivation activation: + RenderActivation(canvas, activation, options); + break; + + case LayoutGrid grid: + RenderGrid(canvas, grid, options); + break; + + default: + // Skip unknown node types for forward compatibility + break; + } + } + + /// + /// Renders a as a filled and stroked rectangle — plain or + /// rounded depending on — with an optional centered label, + /// compartment dividers and rows, then recursively renders its children. + /// + /// Canvas to draw on. + /// Box node to render. + /// Render options providing theme and scale. + private static void RenderBox(SKCanvas canvas, LayoutBox box, RenderOptions options) + { + var theme = options.Theme; + + var strokeColor = SKColor.Parse(theme.StrokeColor); + var fillHex = theme.DepthFillColors[box.Depth % theme.DepthFillColors.Count]; + var fillColor = SKColor.Parse(fillHex); + + // Draw the shape-specific outline (fill + border) + RenderBoxOutline(canvas, box, options, fillColor, strokeColor); + + // Draw the keyword line and bold name label in the title area + RenderBoxTitle(canvas, box, options, strokeColor); + + // Render compartments below the label area with horizontal dividers + if (box.Compartments.Count > 0) + { + RenderBoxCompartments(canvas, box, options, strokeColor); + } + + // Render children recursively + foreach (var child in box.Children) + { + RenderNode(canvas, child, options); + } + } + + /// + /// Draws the fill and border of a , selecting geometry based on + /// . + /// + /// Canvas to draw on. + /// Box whose outline is drawn. + /// Render options providing theme and scale. + /// Fill color for the interior. + /// Stroke color for the border. + private static void RenderBoxOutline( + SKCanvas canvas, + LayoutBox box, + RenderOptions options, + SKColor fillColor, + SKColor strokeColor) + { + var theme = options.Theme; + var scale = (float)options.Scale; + + var x = (float)(box.X * scale); + var y = (float)(box.Y * scale); + var rect = new SKRect(x, y, x + (float)(box.Width * scale), y + (float)(box.Height * scale)); + + using var fillPaint = new SKPaint { Color = fillColor, Style = SKPaintStyle.Fill, IsAntialias = true }; + using var strokePaint = new SKPaint + { + Color = strokeColor, + Style = SKPaintStyle.Stroke, + StrokeWidth = (float)theme.StrokeWidth * scale, + IsAntialias = true, + }; + + switch (box.Shape) + { + case BoxShape.Folder: + using (var path = BuildFolderPath(box, theme, scale)) + { + canvas.DrawPath(path, fillPaint); + canvas.DrawPath(path, strokePaint); + } + + break; + + case BoxShape.Note: + RenderNotePng(canvas, box, scale, fillPaint, strokePaint); + break; + + case BoxShape.RoundedRectangle when theme.LineCornerRadius > 0: + var cornerR = (float)(NotationMetrics.RoundedRectRadius(theme) * scale); + canvas.DrawRoundRect(rect, cornerR, cornerR, fillPaint); + canvas.DrawRoundRect(rect, cornerR, cornerR, strokePaint); + break; + + default: + canvas.DrawRect(rect, fillPaint); + canvas.DrawRect(rect, strokePaint); + break; + } + } + + /// + /// Builds the folder outline path (a tab at the top-left above a full-width body). + /// + private static SKPath BuildFolderPath(LayoutBox box, Theme theme, float scale) + { + var tabHeight = BoxMetrics.FolderTabHeight(theme); + var tabWidth = Math.Min( + box.Width * NotationMetrics.FolderTabMaxWidthFraction, + Math.Max( + NotationMetrics.FolderTabMinWidth, + (box.Label?.Length ?? 4) * theme.FontSizeBody * NotationMetrics.FolderLabelCharWidthFactor + 2.0 * theme.LabelPadding)); + + var x = (float)(box.X * scale); + var yTab = (float)(box.Y * scale); + var yBody = (float)((box.Y + tabHeight) * scale); + var xTabRight = (float)((box.X + tabWidth) * scale); + var xRight = (float)((box.X + box.Width) * scale); + var yBottom = (float)((box.Y + box.Height) * scale); + + var path = new SKPath(); + path.MoveTo(x, yBody); + path.LineTo(x, yTab); + path.LineTo(xTabRight, yTab); + path.LineTo(xTabRight, yBody); + path.LineTo(xRight, yBody); + path.LineTo(xRight, yBottom); + path.LineTo(x, yBottom); + path.Close(); + return path; + } + + /// + /// Draws a note-shaped box (a rectangle with a folded-down top-right corner). + /// + private static void RenderNotePng( + SKCanvas canvas, + LayoutBox box, + float scale, + SKPaint fillPaint, + SKPaint strokePaint) + { + var fold = Math.Min(Math.Min(box.Width, box.Height) * NotationMetrics.NoteFoldFraction, NotationMetrics.NoteFoldMaxSize); + + var x = (float)(box.X * scale); + var y = (float)(box.Y * scale); + var xRight = (float)((box.X + box.Width) * scale); + var xFold = (float)((box.X + box.Width - fold) * scale); + var yFold = (float)((box.Y + fold) * scale); + var yBottom = (float)((box.Y + box.Height) * scale); + + using var body = new SKPath(); + body.MoveTo(x, y); + body.LineTo(xFold, y); + body.LineTo(xRight, yFold); + body.LineTo(xRight, yBottom); + body.LineTo(x, yBottom); + body.Close(); + canvas.DrawPath(body, fillPaint); + canvas.DrawPath(body, strokePaint); + + using var corner = new SKPath(); + corner.MoveTo(xFold, y); + corner.LineTo(xFold, yFold); + corner.LineTo(xRight, yFold); + canvas.DrawPath(corner, strokePaint); + } + + /// + /// Draws the optional keyword line and bold name label in the title area of a box. + /// + /// Canvas to draw on. + /// Box whose title is drawn. + /// Render options providing theme and scale. + /// Text color. + private static void RenderBoxTitle(SKCanvas canvas, LayoutBox box, RenderOptions options, SKColor strokeColor) + { + var theme = options.Theme; + var scale = (float)options.Scale; + var centerX = (float)((box.X + box.Width / 2.0) * scale); + var cursorY = box.Y + theme.LabelPadding; + + // Keyword line (smaller, italic, guillemet-wrapped) above the name + if (box.Keyword != null) + { + using var kwPaint = CreateTextPaint(strokeColor, (float)theme.FontSizeBody * scale, bold: false, italic: true); + kwPaint.TextAlign = SKTextAlign.Center; + var kwY = (float)((cursorY + theme.FontSizeBody) * scale); + canvas.DrawText("\u00AB" + box.Keyword + "\u00BB", centerX, kwY, kwPaint); + cursorY += theme.FontSizeBody + theme.LabelPadding; + } + + // Bold name label, shrink-to-fit + if (box.Label != null) + { + using var textPaint = CreateTextPaint(strokeColor, (float)theme.FontSizeTitle * scale, bold: true, italic: false); + textPaint.TextAlign = SKTextAlign.Center; + var availableWidth = (float)((box.Width - 2 * theme.LabelPadding) * scale); + textPaint.TextSize = FitFontSize(textPaint, box.Label, availableWidth, textPaint.TextSize); + var textY = (float)((cursorY + theme.FontSizeTitle) * scale); + canvas.DrawText(box.Label, centerX, textY, textPaint); + } + } + + /// + /// Renders the compartments of a below the title area. + /// Each compartment begins with a full-width horizontal divider, followed by an optional + /// bold title row and then zero or more body-font text rows, each indented by + /// . + /// + /// Canvas to draw on. + /// Box whose compartments are rendered. + /// Render options providing theme and scale. + /// Pre-parsed stroke color reused across all compartment draws. + private static void RenderBoxCompartments( + SKCanvas canvas, + LayoutBox box, + RenderOptions options, + SKColor strokeColor) + { + var theme = options.Theme; + var scale = (float)options.Scale; + + // Compartments start below the title area (keyword + label), computed via shared metrics + var labelAreaHeight = BoxMetrics.TitleAreaHeight(theme, box.Label != null, box.Keyword != null); + var compartmentY = box.Y + labelAreaHeight; + + foreach (var compartment in box.Compartments) + { + // Draw a full-width horizontal divider at the top of this compartment + using (var divPaint = new SKPaint()) + { + divPaint.Color = strokeColor; + divPaint.Style = SKPaintStyle.Stroke; + divPaint.StrokeWidth = (float)theme.StrokeWidth * scale; + canvas.DrawLine( + (float)(box.X * scale), + (float)(compartmentY * scale), + (float)((box.X + box.Width) * scale), + (float)(compartmentY * scale), + divPaint); + } + + // Draw the optional bold compartment title + if (compartment.Title != null) + { + using var titlePaint = CreateTextPaint(strokeColor, (float)theme.FontSizeBody * scale, bold: true, italic: true); + titlePaint.TextAlign = SKTextAlign.Left; + var titleX = (float)((box.X + theme.LabelPadding) * scale); + var titleY = (float)((compartmentY + theme.LabelPadding + theme.FontSizeBody) * scale); + canvas.DrawText(compartment.Title, titleX, titleY, titlePaint); + compartmentY += theme.LabelPadding + theme.FontSizeBody + theme.LabelPadding; + } + + // Draw each body row at body font size, left-aligned with LabelPadding indent + foreach (var row in compartment.Rows) + { + using var rowPaint = CreateTextPaint(strokeColor, (float)theme.FontSizeBody * scale, bold: false, italic: false); + rowPaint.TextAlign = SKTextAlign.Left; + var rowX = (float)((box.X + theme.LabelPadding) * scale); + var rowY = (float)((compartmentY + theme.LabelPadding + theme.FontSizeBody) * scale); + canvas.DrawText(row, rowX, rowY, rowPaint); + compartmentY += theme.LabelPadding + theme.FontSizeBody; + } + + // Bottom gap so the last row clears the next compartment divider. + compartmentY += theme.LabelPadding; + } + } + + /// + /// Renders a as a corner-radius-aware polyline, built from a + /// single so that CornerPathEffect can round every bend + /// uniformly. Arrowheads are drawn on top of the finished path. An optional midpoint + /// label is centered over the line with a white background rectangle. + /// + /// + /// When is zero (e.g., the Print theme) the path is + /// drawn with sharp corners. When both corner rounding and dashing are active, the two + /// effects are composed so that the dash pattern follows the rounded path. + /// + /// Canvas to draw on. + /// Line node to render. + /// Render options providing theme and scale. + private static void RenderLine(SKCanvas canvas, LayoutLine line, RenderOptions options) + { + // Lines with fewer than 2 waypoints cannot be drawn + if (line.Waypoints.Count < 2) + { + return; + } + + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + + // Build the connector path. Lines that carry an end marker whose decoration would otherwise be + // intruded by a rounded corner are built with explicit, per-end clamped corners; all other + // lines keep the uniform CornerPathEffect so their geometry is byte-identical to before. + var clampCorners = (line.SourceEnd != EndMarkerStyle.None || line.TargetEnd != EndMarkerStyle.None) + && theme.LineCornerRadius > 0 + && NeedsEndCornerClamp(line.Waypoints, theme.LineCornerRadius, line.SourceEnd, line.TargetEnd); + + using var path = clampCorners + ? BuildClampedLinePath(line.Waypoints, theme.LineCornerRadius, scale, line.SourceEnd, line.TargetEnd) + : BuildSimpleLinePath(line.Waypoints, scale); + + using var paint = new SKPaint(); + paint.Color = strokeColor; + paint.Style = SKPaintStyle.Stroke; + paint.StrokeWidth = (float)theme.StrokeWidth * scale; + paint.IsAntialias = true; + + // Apply corner and/or dash path effects based on theme and line style + var cornerRadius = (float)(theme.LineCornerRadius * scale); + var hasDash = line.LineStyle != LineStyle.Solid; + + if (clampCorners) + { + // Corners are already baked into the path; apply only the dash effect when needed. + if (hasDash) + { + float[] intervals = line.LineStyle == LineStyle.Dashed + ? [6f * scale, 3f * scale] + : [2f * scale, 2f * scale]; + paint.PathEffect = SKPathEffect.CreateDash(intervals, 0); + } + } + else if (hasDash && cornerRadius > 0) + { + // Compose: apply corner rounding (inner effect) then dashing (outer effect) + float[] intervals = line.LineStyle == LineStyle.Dashed + ? [6f * scale, 3f * scale] + : [2f * scale, 2f * scale]; + using var dash = SKPathEffect.CreateDash(intervals, 0); + using var corner = SKPathEffect.CreateCorner(cornerRadius); + paint.PathEffect = SKPathEffect.CreateCompose(dash, corner); + } + else if (hasDash) + { + float[] intervals = line.LineStyle == LineStyle.Dashed + ? [6f * scale, 3f * scale] + : [2f * scale, 2f * scale]; + paint.PathEffect = SKPathEffect.CreateDash(intervals, 0); + } + else if (cornerRadius > 0) + { + paint.PathEffect = SKPathEffect.CreateCorner(cornerRadius); + } + + canvas.DrawPath(path, paint); + + // Draw source end marker at the first waypoint, direction pointing away from the line + if (line.SourceEnd != EndMarkerStyle.None) + { + var tip = line.Waypoints[0]; + var next = line.Waypoints[1]; + var (dx, dy) = ComputeDirection(next.X, next.Y, tip.X, tip.Y); + DrawEndMarker( + canvas, + (float)(tip.X * scale), (float)(tip.Y * scale), + (float)dx, (float)dy, + line.SourceEnd, + new EndMarkerPaint(strokeColor, SKColor.Parse(theme.BackgroundColor), (float)theme.StrokeWidth * scale, scale)); + } + + // Draw target end marker at the last waypoint, direction pointing away from the line + if (line.TargetEnd != EndMarkerStyle.None) + { + var n = line.Waypoints.Count; + var tip = line.Waypoints[n - 1]; + var prev = line.Waypoints[n - 2]; + var (dx, dy) = ComputeDirection(prev.X, prev.Y, tip.X, tip.Y); + DrawEndMarker( + canvas, + (float)(tip.X * scale), (float)(tip.Y * scale), + (float)dx, (float)dy, + line.TargetEnd, + new EndMarkerPaint(strokeColor, SKColor.Parse(theme.BackgroundColor), (float)theme.StrokeWidth * scale, scale)); + } + + // Note: the midpoint label is intentionally NOT drawn here. It is drawn in a final pass + // (see RenderLineLabel) so that no later wire can draw over an earlier wire's label. + } + + /// + /// Builds a plain scaled polyline path through all waypoints, used together with the uniform + /// CornerPathEffect for lines that do not need decoration-aware corner clamping. + /// + /// Ordered waypoints. + /// Uniform scale factor. + /// The polyline path. + private static SKPath BuildSimpleLinePath(IReadOnlyList waypoints, float scale) + { + var path = new SKPath(); + path.MoveTo((float)(waypoints[0].X * scale), (float)(waypoints[0].Y * scale)); + for (var i = 1; i < waypoints.Count; i++) + { + path.LineTo((float)(waypoints[i].X * scale), (float)(waypoints[i].Y * scale)); + } + + return path; + } + + /// + /// Returns whether a uniform rounded corner of would intrude into + /// an end-marker decoration, i.e. the straight approach at a decorated end is shorter than the + /// marker's along-line length plus the corner radius. Computed in unscaled notation units. + /// + /// Ordered waypoints (unscaled). + /// Unscaled line corner radius. + /// Source end-marker style. + /// Target end-marker style. + /// true if explicit clamped corners are required; otherwise false. + private static bool NeedsEndCornerClamp( + IReadOnlyList waypoints, + double cornerRadius, + EndMarkerStyle sourceEnd, + EndMarkerStyle targetEnd) + { + // Without an interior corner there is nothing to round, so nothing can intrude. + if (waypoints.Count < 3) + { + return false; + } + + if (sourceEnd != EndMarkerStyle.None) + { + var inLen = Distance(waypoints[0], waypoints[1]); + if (inLen - NotationMetrics.AlongLineLength(sourceEnd) < cornerRadius) + { + return true; + } + } + + if (targetEnd != EndMarkerStyle.None) + { + var n = waypoints.Count; + var outLen = Distance(waypoints[n - 2], waypoints[n - 1]); + if (outLen - NotationMetrics.AlongLineLength(targetEnd) < cornerRadius) + { + return true; + } + } + + return false; + } + + /// + /// Builds a scaled connector path with arc-rounded interior corners, mirroring the SVG renderer's + /// BuildLinePath: the first interior corner additionally clamps its radius to + /// inLen − AlongLineLength(sourceEnd) and the last to + /// outLen − AlongLineLength(targetEnd), so the rounded corner never intrudes into an end + /// decoration. Corners are baked into the geometry, so no CornerPathEffect is applied. + /// + /// Ordered waypoints (unscaled); at least two entries. + /// Unscaled line corner radius. + /// Uniform scale factor. + /// Source end-marker style. + /// Target end-marker style. + /// The corner-baked connector path. + private static SKPath BuildClampedLinePath( + IReadOnlyList waypoints, + double cornerRadius, + float scale, + EndMarkerStyle sourceEnd, + EndMarkerStyle targetEnd) + { + var path = new SKPath(); + var first = waypoints[0]; + path.MoveTo((float)(first.X * scale), (float)(first.Y * scale)); + + for (var i = 1; i < waypoints.Count; i++) + { + var cur = waypoints[i]; + var isInterior = i < waypoints.Count - 1; + if (!isInterior) + { + path.LineTo((float)(cur.X * scale), (float)(cur.Y * scale)); + continue; + } + + var prev = waypoints[i - 1]; + var next = waypoints[i + 1]; + + var inDx = cur.X - prev.X; + var inDy = cur.Y - prev.Y; + var inLen = Math.Sqrt(inDx * inDx + inDy * inDy); + var outDx = next.X - cur.X; + var outDy = next.Y - cur.Y; + var outLen = Math.Sqrt(outDx * outDx + outDy * outDy); + + if (inLen < 0.001 || outLen < 0.001) + { + path.LineTo((float)(cur.X * scale), (float)(cur.Y * scale)); + continue; + } + + var inNx = inDx / inLen; + var inNy = inDy / inLen; + var outNx = outDx / outLen; + var outNy = outDy / outLen; + + var r = Math.Min(cornerRadius, Math.Min(inLen / 2.0, outLen / 2.0)); + if (i == 1) + { + r = Math.Min(r, inLen - NotationMetrics.AlongLineLength(sourceEnd)); + } + + if (i == waypoints.Count - 2) + { + r = Math.Min(r, outLen - NotationMetrics.AlongLineLength(targetEnd)); + } + + if (r <= 0.0) + { + path.LineTo((float)(cur.X * scale), (float)(cur.Y * scale)); + continue; + } + + var shortEndX = cur.X - inNx * r; + var shortEndY = cur.Y - inNy * r; + var shortStartX = cur.X + outNx * r; + var shortStartY = cur.Y + outNy * r; + + var cross = inNx * outNy - inNy * outNx; + var dir = cross > 0 ? SKPathDirection.Clockwise : SKPathDirection.CounterClockwise; + + path.LineTo((float)(shortEndX * scale), (float)(shortEndY * scale)); + path.ArcTo( + (float)(r * scale), (float)(r * scale), 0, SKPathArcSize.Small, dir, + (float)(shortStartX * scale), (float)(shortStartY * scale)); + } + + return path; + } + + /// Returns the Euclidean distance between two points. + /// First point. + /// Second point. + /// The distance. + private static double Distance(Point2D a, Point2D b) + { + var dx = a.X - b.X; + var dy = a.Y - b.Y; + return Math.Sqrt(dx * dx + dy * dy); + } + + /// + /// Draws a line's optional midpoint label, called in a final pass after all wires and boxes are + /// drawn so labels are never drawn over by another wire. + /// + /// Canvas to draw on. + /// The line whose label is rendered. + /// Render options providing theme and scale. + /// Pre-computed label centre X in logical pixels. + /// Pre-computed label centre Y in logical pixels. + private static void RenderLineLabel(SKCanvas canvas, LayoutLine line, RenderOptions options, double midX, double midY) + { + if (line.MidpointLabel is null) + { + return; + } + + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + RenderLineMidpointLabel(canvas, midX, midY, line.MidpointLabel, theme, scale, strokeColor); + } + + /// Recursively collects all nodes from a node tree. + /// Top-level nodes to walk. + /// Every line node, including those nested inside boxes or bands. + private static IEnumerable CollectLines(IReadOnlyList nodes) + { + foreach (var node in nodes) + { + switch (node) + { + case LayoutLine line: + yield return line; + break; + + case LayoutBox box: + foreach (var inner in CollectLines(box.Children)) + { + yield return inner; + } + + break; + + case LayoutBand band: + foreach (var inner in CollectLines(band.Children)) + { + yield return inner; + } + + break; + + default: + break; + } + } + } + + /// + /// Groups the visual paint parameters for line-end marker rendering, reducing the parameter + /// count on to within the allowed limit. + /// + /// Stroke and fill color for the end marker. + /// Background fill for hollow enclosing markers, so the connector line does not show through. + /// Stroke width applied to open (non-filled) end-marker styles. + /// Uniform scale factor used to size the end marker relative to the diagram. + private readonly record struct EndMarkerPaint(SKColor Color, SKColor BackgroundColor, float StrokeWidth, float Scale); + + /// + /// Maps a tip-relative to a scaled canvas point, shared by all marker + /// shapes so the PNG draws the identical geometry as the SVG marker definitions. + /// + /// + /// The endpoint (tip) is the line end; (dx, dy) points outward along the line and + /// (px, py) is its left perpendicular. A vertex at distance Along back from the tip + /// and Across to the side maps to tip − dir·Along·scale + perp·Across·scale, so a + /// negative Along (the triangle apex) overshoots the tip outward. + /// + /// Scaled X coordinate of the line endpoint. + /// Scaled Y coordinate of the line endpoint. + /// X component of the outward unit direction. + /// Y component of the outward unit direction. + /// X component of the perpendicular unit direction. + /// Y component of the perpendicular unit direction. + /// The tip-relative marker vertex in notation units. + /// Uniform scale factor. + /// The mapped canvas point. + private static SKPoint MarkerPoint( + float tipX, float tipY, float dx, float dy, float px, float py, MarkerVertex vertex, float scale) + { + var along = (float)vertex.Along; + var across = (float)vertex.Across; + return new SKPoint( + tipX - dx * along * scale + px * across * scale, + tipY - dy * along * scale + py * across * scale); + } + + /// + /// Draws a line-end marker of the specified style at a line endpoint, with every coordinate + /// derived from so the PNG marker matches the SVG marker exactly. + /// + /// + /// The direction vector (, ) must be a unit vector + /// pointing from the line body outward through the tip. The triangle, filled arrow, and both + /// diamonds reuse / + /// ; draws + /// those triangle vertices as an OPEN two-stroke polyline (no closing base edge), whereas + /// closes the path. + /// + /// Canvas to draw on. + /// Scaled X coordinate of the line endpoint. + /// Scaled Y coordinate of the line endpoint. + /// X component of the normalized outward direction vector. + /// Y component of the normalized outward direction vector. + /// End-marker style to draw; is a no-op. + /// Color, stroke width, and scale parameters for the end marker. + private static void DrawEndMarker( + SKCanvas canvas, + float tipX, float tipY, + float dx, float dy, + EndMarkerStyle style, + EndMarkerPaint paint) + { + if (style == EndMarkerStyle.None) + { + return; + } + + // Perpendicular direction (90° CCW rotation of the outward vector) + var px = -dy; + var py = dx; + var scale = paint.Scale; + + using var paintObj = new SKPaint(); + paintObj.Color = paint.Color; + paintObj.IsAntialias = true; + paintObj.StrokeWidth = paint.StrokeWidth; + + switch (style) + { + case EndMarkerStyle.OpenChevron: + { + // OPEN chevron: two strokes meeting at the apex, no closing base edge. + paintObj.Style = SKPaintStyle.Stroke; + using var p = TrianglePath(tipX, tipY, dx, dy, px, py, scale, close: false); + canvas.DrawPath(p, paintObj); + break; + } + + case EndMarkerStyle.HollowTriangle: + { + // Closed hollow triangle, background-filled so the line does not show through. + using var p = TrianglePath(tipX, tipY, dx, dy, px, py, scale, close: true); + paintObj.Style = SKPaintStyle.Fill; + paintObj.Color = paint.BackgroundColor; + canvas.DrawPath(p, paintObj); + paintObj.Style = SKPaintStyle.Stroke; + paintObj.Color = paint.Color; + canvas.DrawPath(p, paintObj); + break; + } + + case EndMarkerStyle.HollowTriangleCrossbar: + { + // Closed hollow triangle (background-filled) + perpendicular crossbar on the shaft. + using var p = TrianglePath(tipX, tipY, dx, dy, px, py, scale, close: true); + paintObj.Style = SKPaintStyle.Fill; + paintObj.Color = paint.BackgroundColor; + canvas.DrawPath(p, paintObj); + paintObj.Style = SKPaintStyle.Stroke; + paintObj.Color = paint.Color; + canvas.DrawPath(p, paintObj); + + var crossAlong = NotationMetrics.EndMarkerRefX - NotationMetrics.CrossbarX; + var a = MarkerPoint(tipX, tipY, dx, dy, px, py, new MarkerVertex(crossAlong, -NotationMetrics.EndMarkerHalfWidth), scale); + var b = MarkerPoint(tipX, tipY, dx, dy, px, py, new MarkerVertex(crossAlong, NotationMetrics.EndMarkerHalfWidth), scale); + canvas.DrawLine(a.X, a.Y, b.X, b.Y, paintObj); + break; + } + + case EndMarkerStyle.FilledArrow: + { + // Filled solid triangle. + paintObj.Style = SKPaintStyle.Fill; + using var p = TrianglePath(tipX, tipY, dx, dy, px, py, scale, close: true); + canvas.DrawPath(p, paintObj); + break; + } + + case EndMarkerStyle.HollowDiamond: + { + // Hollow four-point diamond, background-filled so the line does not show through. + using var p = DiamondPath(tipX, tipY, dx, dy, px, py, scale); + paintObj.Style = SKPaintStyle.Fill; + paintObj.Color = paint.BackgroundColor; + canvas.DrawPath(p, paintObj); + paintObj.Style = SKPaintStyle.Stroke; + paintObj.Color = paint.Color; + canvas.DrawPath(p, paintObj); + break; + } + + case EndMarkerStyle.FilledDiamond: + { + // Filled four-point diamond. + paintObj.Style = SKPaintStyle.Fill; + using var p = DiamondPath(tipX, tipY, dx, dy, px, py, scale); + canvas.DrawPath(p, paintObj); + break; + } + + case EndMarkerStyle.Circle: + { + // Hollow circle (background-filled) whose near edge touches the tip; center pulled back by one radius. + var center = MarkerPoint( + tipX, tipY, dx, dy, px, py, new MarkerVertex(NotationMetrics.CircleRadius, 0.0), scale); + var r = (float)NotationMetrics.CircleRadius * scale; + paintObj.Style = SKPaintStyle.Fill; + paintObj.Color = paint.BackgroundColor; + canvas.DrawCircle(center.X, center.Y, r, paintObj); + paintObj.Style = SKPaintStyle.Stroke; + paintObj.Color = paint.Color; + canvas.DrawCircle(center.X, center.Y, r, paintObj); + break; + } + + case EndMarkerStyle.Bar: + { + // Perpendicular bar centered on the tip. + paintObj.Style = SKPaintStyle.Stroke; + var a = MarkerPoint(tipX, tipY, dx, dy, px, py, new MarkerVertex(0.0, NotationMetrics.BarHalf), scale); + var b = MarkerPoint(tipX, tipY, dx, dy, px, py, new MarkerVertex(0.0, -NotationMetrics.BarHalf), scale); + canvas.DrawLine(a.X, a.Y, b.X, b.Y, paintObj); + break; + } + + default: + // Unknown styles are treated as None + break; + } + } + + /// + /// Builds the triangle marker path from , optionally + /// closing the base edge (closed for the hollow/filled triangle, open for the chevron). + /// + private static SKPath TrianglePath( + float tipX, float tipY, float dx, float dy, float px, float py, float scale, bool close) + { + var vertices = NotationMetrics.TriangleVertices(); + var p = new SKPath(); + var v0 = MarkerPoint(tipX, tipY, dx, dy, px, py, vertices[0], scale); + p.MoveTo(v0.X, v0.Y); + for (var i = 1; i < vertices.Count; i++) + { + var v = MarkerPoint(tipX, tipY, dx, dy, px, py, vertices[i], scale); + p.LineTo(v.X, v.Y); + } + + if (close) + { + p.Close(); + } + + return p; + } + + /// + /// Builds the closed diamond marker path from . + /// + private static SKPath DiamondPath( + float tipX, float tipY, float dx, float dy, float px, float py, float scale) + { + var vertices = NotationMetrics.DiamondVertices(); + var p = new SKPath(); + var v0 = MarkerPoint(tipX, tipY, dx, dy, px, py, vertices[0], scale); + p.MoveTo(v0.X, v0.Y); + for (var i = 1; i < vertices.Count; i++) + { + var v = MarkerPoint(tipX, tipY, dx, dy, px, py, vertices[i], scale); + p.LineTo(v.X, v.Y); + } + + p.Close(); + return p; + } + + /// + /// Computes a normalized direction unit vector from (, + /// ) toward (, ). + /// Returns (1, 0) as a safe fallback when the two points coincide. + /// + /// X coordinate of the source point. + /// Y coordinate of the source point. + /// X coordinate of the target point. + /// Y coordinate of the target point. + /// Normalized (Dx, Dy) direction tuple. + private static (double Dx, double Dy) ComputeDirection( + double fromX, double fromY, double toX, double toY) + { + var dx = toX - fromX; + var dy = toY - fromY; + var len = Math.Sqrt(dx * dx + dy * dy); + return len < 0.001 ? (1.0, 0.0) : (dx / len, dy / len); + } + + /// + /// Renders a text label centered at the midpoint of a polyline, with a white background + /// rectangle drawn first to ensure readability over the line stroke. + /// + /// Canvas to draw on. + /// Label centre X in logical pixels. + /// Label centre Y in logical pixels. + /// Label text to render. + /// Theme providing font size and padding. + /// Uniform scale factor. + /// Color used for the label text. + private static void RenderLineMidpointLabel( + SKCanvas canvas, + double midX, + double midY, + string label, + Theme theme, + float scale, + SKColor strokeColor) + { + var scaledX = (float)(midX * scale); + var scaledY = (float)(midY * scale); + + using var textPaint = CreateTextPaint(strokeColor, (float)theme.FontSizeBody * scale, bold: false, italic: false); + textPaint.TextAlign = SKTextAlign.Center; + + // Measure the text so the background rectangle fits snugly around it + var textWidth = textPaint.MeasureText(label); + var textHeight = (float)theme.FontSizeBody * scale; + var padding = (float)theme.LabelPadding * scale * 0.5f; + var bgRect = new SKRect( + scaledX - textWidth / 2f - padding, + scaledY - textHeight - padding, + scaledX + textWidth / 2f + padding, + scaledY + padding); + + using (var bgPaint = new SKPaint()) + { + // Occlude the connector line behind the label using the theme background, matching the + // canvas fill so the label reads cleanly under non-white themes. + bgPaint.Color = SKColor.Parse(theme.BackgroundColor); + bgPaint.Style = SKPaintStyle.Fill; + canvas.DrawRect(bgRect, bgPaint); + } + + canvas.DrawText(label, scaledX, scaledY, textPaint); + } + + /// + /// Renders a as a text element at its absolute position. + /// + /// Canvas to draw on. + /// Label node to render. + /// Render options providing theme and scale. + private static void RenderLabel(SKCanvas canvas, LayoutLabel label, RenderOptions options) + { + var theme = options.Theme; + var scale = (float)options.Scale; + + using var paint = CreateTextPaint( + SKColor.Parse(theme.StrokeColor), + (float)label.FontSize * scale, + bold: label.Weight == FontWeight.Bold, + italic: label.Style == FontStyle.Italic); + paint.TextAlign = label.Align switch + { + TextAlign.Center => SKTextAlign.Center, + TextAlign.Right => SKTextAlign.Right, + _ => SKTextAlign.Left + }; + + var availableWidth = (float)(label.MaxWidth * scale); + paint.TextSize = FitFontSize(paint, label.Text, availableWidth, paint.TextSize); + + canvas.DrawText(label.Text, (float)(label.X * scale), (float)(label.Y * scale), paint); + } + + /// + /// Renders a as a small (8×8 logical pixels) filled square + /// centered at the port position. When a label is present it is offset away from the + /// edge the port is attached to, ensuring it does not overlap with the host box. + /// + /// Canvas to draw on. + /// Port node to render. + /// Render options providing theme and scale. + private static void RenderPort(SKCanvas canvas, LayoutPort port, RenderOptions options) + { + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + + // Port square: filled, centered at (CentreX, CentreY), sized from NotationMetrics. + var portRect = new SKRect( + (float)((port.CentreX - NotationMetrics.PortHalfSize) * scale), + (float)((port.CentreY - NotationMetrics.PortHalfSize) * scale), + (float)((port.CentreX + NotationMetrics.PortHalfSize) * scale), + (float)((port.CentreY + NotationMetrics.PortHalfSize) * scale)); + + // Ports are conventionally drawn as filled squares using the stroke color + using (var fillPaint = new SKPaint()) + { + fillPaint.Color = strokeColor; + fillPaint.Style = SKPaintStyle.Fill; + canvas.DrawRect(portRect, fillPaint); + } + + // Draw the optional label offset away from the attached edge + if (port.Label != null) + { + // Offset the label far enough from the port square so it does not overlap + var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; + var (labelX, labelY, align) = port.Side switch + { + PortSide.Top => (port.CentreX, port.CentreY - offset, SKTextAlign.Center), + PortSide.Bottom => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, SKTextAlign.Center), + PortSide.Left => (port.CentreX - offset, port.CentreY + theme.FontSizeBody / 2.0, SKTextAlign.Right), + _ => (port.CentreX + offset, port.CentreY + theme.FontSizeBody / 2.0, SKTextAlign.Left) + }; + + using var textPaint = CreateTextPaint(strokeColor, (float)theme.FontSizeBody * scale, bold: false, italic: false); + textPaint.TextAlign = align; + canvas.DrawText(port.Label, (float)(labelX * scale), (float)(labelY * scale), textPaint); + } + } + + /// + /// Renders a as the specified icon shape centered at the badge + /// position. An optional label is drawn to the right of the bounding circle. + /// + /// Canvas to draw on. + /// Badge node to render. + /// Render options providing theme and scale. + private static void RenderBadge(SKCanvas canvas, LayoutBadge badge, RenderOptions options) + { + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + + var cx = (float)(badge.CentreX * scale); + var cy = (float)(badge.CentreY * scale); + var r = (float)(badge.Size / 2.0 * scale); + + using var strokePaint = new SKPaint(); + strokePaint.Color = strokeColor; + strokePaint.Style = SKPaintStyle.Stroke; + strokePaint.StrokeWidth = (float)theme.StrokeWidth * scale; + strokePaint.IsAntialias = true; + + using var fillPaint = new SKPaint(); + fillPaint.Color = strokeColor; + fillPaint.Style = SKPaintStyle.Fill; + fillPaint.IsAntialias = true; + + // Draw the badge shape centered at (cx, cy) within a bounding circle of radius r + switch (badge.Shape) + { + case BadgeShape.FilledCircle: + canvas.DrawCircle(cx, cy, r, fillPaint); + break; + + case BadgeShape.Bullseye: + { + // Outer filled circle + white inner circle to create a visible ring + canvas.DrawCircle(cx, cy, r, fillPaint); + using var innerWhite = new SKPaint(); + innerWhite.Color = SKColors.White; + innerWhite.Style = SKPaintStyle.Fill; + innerWhite.IsAntialias = true; + canvas.DrawCircle(cx, cy, r * (float)NotationMetrics.BadgeBullseyeInnerFraction, innerWhite); + canvas.DrawCircle(cx, cy, r * (float)NotationMetrics.BadgeBullseyeInnerFraction, strokePaint); + break; + } + + case BadgeShape.Diamond: + { + // Open rotated-square diamond with vertices at the compass cardinal points + using var p = new SKPath(); + p.MoveTo(cx, cy - r); // top + p.LineTo(cx + r, cy); // right + p.LineTo(cx, cy + r); // bottom + p.LineTo(cx - r, cy); // left + p.Close(); + canvas.DrawPath(p, strokePaint); + break; + } + + case BadgeShape.HorizontalBar: + canvas.DrawLine(cx - r * (float)NotationMetrics.BadgeBarLengthFraction, cy, cx + r * (float)NotationMetrics.BadgeBarLengthFraction, cy, strokePaint); + break; + + case BadgeShape.VerticalBar: + canvas.DrawLine(cx, cy - r * (float)NotationMetrics.BadgeBarLengthFraction, cx, cy + r * (float)NotationMetrics.BadgeBarLengthFraction, strokePaint); + break; + + default: + // Unknown badge shapes are skipped for forward compatibility + break; + } + + // Draw the optional label to the right of the bounding circle + if (badge.Label != null) + { + using var textPaint = CreateTextPaint(strokeColor, (float)theme.FontSizeBody * scale, bold: false, italic: false); + textPaint.TextAlign = SKTextAlign.Left; + var labelX = (float)((badge.CentreX + badge.Size / 2.0 + theme.LabelPadding) * scale); + var labelY = (float)((badge.CentreY + theme.FontSizeBody / 2.0) * scale); + canvas.DrawText(badge.Label, labelX, labelY, textPaint); + } + } + + /// + /// Renders a as a swim-lane rectangle with an optional label. + /// For Horizontal bands the label is rendered vertically (rotated 90° CCW) along the + /// left edge; for Vertical bands it is rendered horizontally at the top. Children are + /// rendered recursively. + /// + /// Canvas to draw on. + /// Band node to render. + /// Render options providing theme and scale. + private static void RenderBand(SKCanvas canvas, LayoutBand band, RenderOptions options) + { + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + + var rect = new SKRect( + (float)(band.X * scale), + (float)(band.Y * scale), + (float)((band.X + band.Width) * scale), + (float)((band.Y + band.Height) * scale)); + + // Fill band with the primary (depth-0) background color + using (var fillPaint = new SKPaint()) + { + fillPaint.Color = SKColor.Parse(theme.DepthFillColors[0]); + fillPaint.Style = SKPaintStyle.Fill; + canvas.DrawRect(rect, fillPaint); + } + + // Draw the band border + using (var strokePaint = new SKPaint()) + { + strokePaint.Color = strokeColor; + strokePaint.Style = SKPaintStyle.Stroke; + strokePaint.StrokeWidth = (float)theme.StrokeWidth * scale; + canvas.DrawRect(rect, strokePaint); + } + + // Draw the optional label; position and rotation depends on band orientation + if (band.Label != null) + { + using var textPaint = CreateTextPaint(strokeColor, (float)theme.FontSizeBody * scale, bold: false, italic: false); + textPaint.TextAlign = SKTextAlign.Center; + + if (band.Orientation == BandOrientation.Horizontal) + { + // Vertical text on the left edge: translate to label center, rotate CCW + var labelCx = (float)((band.X + theme.LabelPadding + theme.FontSizeBody / 2.0) * scale); + var labelCy = (float)((band.Y + band.Height / 2.0) * scale); + canvas.Save(); + canvas.Translate(labelCx, labelCy); + canvas.RotateDegrees(-90); + canvas.DrawText(band.Label, 0, 0, textPaint); + canvas.Restore(); + } + else + { + // Horizontal text at the top of the band + var textX = (float)((band.X + band.Width / 2.0) * scale); + var textY = (float)((band.Y + theme.LabelPadding + theme.FontSizeBody) * scale); + canvas.DrawText(band.Label, textX, textY, textPaint); + } + } + + // Render children recursively + foreach (var child in band.Children) + { + RenderNode(canvas, child, options); + } + } + + /// + /// Renders a as a header box centered at + /// containing the lifeline label, followed by a + /// dashed vertical stem running from the bottom of the header to + /// . + /// + /// Canvas to draw on. + /// Lifeline node to render. + /// Render options providing theme and scale. + private static void RenderLifeline(SKCanvas canvas, LayoutLifeline lifeline, RenderOptions options) + { + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + + // Header box: centered at CentreX, top edge at TopY + var headerLeft = lifeline.CentreX - lifeline.HeaderWidth / 2.0; + var headerRect = new SKRect( + (float)(headerLeft * scale), + (float)(lifeline.TopY * scale), + (float)((headerLeft + lifeline.HeaderWidth) * scale), + (float)((lifeline.TopY + lifeline.HeaderHeight) * scale)); + + // Fill header with the primary background color + using (var fillPaint = new SKPaint()) + { + fillPaint.Color = SKColor.Parse(theme.DepthFillColors[0]); + fillPaint.Style = SKPaintStyle.Fill; + canvas.DrawRect(headerRect, fillPaint); + } + + // Draw header border + using (var strokePaint = new SKPaint()) + { + strokePaint.Color = strokeColor; + strokePaint.Style = SKPaintStyle.Stroke; + strokePaint.StrokeWidth = (float)theme.StrokeWidth * scale; + canvas.DrawRect(headerRect, strokePaint); + } + + // Draw the header label centered within the header box + using (var textPaint = CreateTextPaint(strokeColor, (float)theme.FontSizeBody * scale, bold: true, italic: false)) + { + textPaint.TextAlign = SKTextAlign.Center; + var textX = (float)(lifeline.CentreX * scale); + var textY = (float)((lifeline.TopY + (lifeline.HeaderHeight + theme.FontSizeBody) / 2.0) * scale); + canvas.DrawText(lifeline.Label, textX, textY, textPaint); + } + + // Dashed vertical stem from the bottom of the header box to BottomY + using var stemPaint = new SKPaint(); + stemPaint.Color = strokeColor; + stemPaint.Style = SKPaintStyle.Stroke; + stemPaint.StrokeWidth = (float)theme.StrokeWidth * scale; + stemPaint.IsAntialias = true; + stemPaint.PathEffect = SKPathEffect.CreateDash([6f * scale, 3f * scale], 0); + + var stemX = (float)(lifeline.CentreX * scale); + canvas.DrawLine( + stemX, (float)((lifeline.TopY + lifeline.HeaderHeight) * scale), + stemX, (float)(lifeline.BottomY * scale), + stemPaint); + } + + /// + /// Renders a as a narrow white-filled rectangle with a + /// stroke border, centered horizontally at . + /// + /// + /// The activation bar width is Theme.LabelPadding * 2, giving it a size that + /// scales proportionally with the diagram's text padding setting. + /// + /// Canvas to draw on. + /// Activation node to render. + /// Render options providing theme and scale. + private static void RenderActivation(SKCanvas canvas, LayoutActivation activation, RenderOptions options) + { + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + + // Bar width = LabelPadding * 2, centered at CentreX + var halfWidth = theme.LabelPadding; + var rect = new SKRect( + (float)((activation.CentreX - halfWidth) * scale), + (float)(activation.TopY * scale), + (float)((activation.CentreX + halfWidth) * scale), + (float)(activation.BottomY * scale)); + + // White fill indicates the lifeline is active during this time interval + using (var fillPaint = new SKPaint()) + { + fillPaint.Color = SKColors.White; + fillPaint.Style = SKPaintStyle.Fill; + canvas.DrawRect(rect, fillPaint); + } + + // Stroke border delineates the bar from surrounding elements + using var strokePaint = new SKPaint(); + strokePaint.Color = strokeColor; + strokePaint.Style = SKPaintStyle.Stroke; + strokePaint.StrokeWidth = (float)theme.StrokeWidth * scale; + canvas.DrawRect(rect, strokePaint); + } + + /// + /// Renders a as a bordered table. Header rows are filled with + /// the depth-1 theme color; body rows use the depth-0 color. Each cell's text is + /// aligned according to and vertically centered + /// within the row height. + /// + /// Canvas to draw on. + /// Grid node to render. + /// Render options providing theme and scale. + private static void RenderGrid(SKCanvas canvas, LayoutGrid grid, RenderOptions options) + { + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + + // Header rows use depth-1 color; body rows use depth-0 color + var headerFill = SKColor.Parse(theme.DepthFillColors[1 % theme.DepthFillColors.Count]); + var bodyFill = SKColor.Parse(theme.DepthFillColors[0]); + + // Accumulate Y position across rows; X resets at the start of each row + var currentY = grid.Y; + foreach (var row in grid.Rows) + { + // Row height = maximum cell height in this row + var rowHeight = 0.0; + foreach (var cell in row.Cells) + { + rowHeight = Math.Max(rowHeight, cell.Height); + } + + var currentX = grid.X; + foreach (var cell in row.Cells) + { + var cellRect = new SKRect( + (float)(currentX * scale), + (float)(currentY * scale), + (float)((currentX + cell.Width) * scale), + (float)((currentY + rowHeight) * scale)); + + // Fill cell with header or body background + using (var fillPaint = new SKPaint()) + { + fillPaint.Color = row.IsHeader ? headerFill : bodyFill; + fillPaint.Style = SKPaintStyle.Fill; + canvas.DrawRect(cellRect, fillPaint); + } + + // Draw the cell border + using (var borderPaint = new SKPaint()) + { + borderPaint.Color = strokeColor; + borderPaint.Style = SKPaintStyle.Stroke; + borderPaint.StrokeWidth = (float)theme.StrokeWidth * scale; + canvas.DrawRect(cellRect, borderPaint); + } + + // Draw cell text, horizontally aligned per cell spec and vertically centered + using (var textPaint = CreateTextPaint(strokeColor, (float)theme.FontSizeBody * scale, bold: row.IsHeader, italic: false)) + { + textPaint.TextAlign = cell.Align switch + { + TextAlign.Center => SKTextAlign.Center, + TextAlign.Right => SKTextAlign.Right, + _ => SKTextAlign.Left + }; + + var textX = cell.Align switch + { + TextAlign.Center => currentX + cell.Width / 2.0, + TextAlign.Right => currentX + cell.Width - theme.LabelPadding, + _ => currentX + theme.LabelPadding + }; + + // Vertically center the baseline within the row + var textY = currentY + (rowHeight + theme.FontSizeBody) / 2.0; + canvas.DrawText(cell.Text, (float)(textX * scale), (float)(textY * scale), textPaint); + } + + currentX += cell.Width; + } + + currentY += rowHeight; + } + } +} + diff --git a/src/DemaConsulting.Rendering.Skia/WebpRenderer.cs b/src/DemaConsulting.Rendering.Skia/WebpRenderer.cs new file mode 100644 index 0000000..0e3283a --- /dev/null +++ b/src/DemaConsulting.Rendering.Skia/WebpRenderer.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using SkiaSharp; + +namespace DemaConsulting.Rendering.Skia; + +/// +/// Renders a to a WEBP image using SkiaSharp at a high quality setting. +/// +public sealed class WebpRenderer : SkiaRasterRenderer +{ + /// + protected override SKEncodedImageFormat EncodedFormat => SKEncodedImageFormat.Webp; + + /// + protected override int EncodingQuality => 90; + + /// + public override string MediaType => "image/webp"; + + /// + public override string DefaultExtension => ".webp"; + + /// + public override IReadOnlyList FileExtensions => [".webp"]; +} diff --git a/src/DemaConsulting.Rendering.Svg/DemaConsulting.Rendering.Svg.csproj b/src/DemaConsulting.Rendering.Svg/DemaConsulting.Rendering.Svg.csproj new file mode 100644 index 0000000..7516099 --- /dev/null +++ b/src/DemaConsulting.Rendering.Svg/DemaConsulting.Rendering.Svg.csproj @@ -0,0 +1,85 @@ + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + + DemaConsulting.Rendering.Svg + 0.0.0 + DEMA Consulting + DEMA Consulting + SVG renderer for the DEMA Consulting rendering library, with zero external dependencies + MIT + https://github.com/demaconsulting/Rendering + https://github.com/demaconsulting/Rendering + README.md + Icon.png + rendering;svg;dotnet-library + Copyright DEMA Consulting + Rendering SVG Renderer + Rendering + + + true + snupkg + true + true + true + + + true + true + true + true + true + latest + + + true + + + true + $(PackageId) + $(Version) + Organization: $(Company) + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/DemaConsulting.Rendering.Svg/NamespaceDoc.cs b/src/DemaConsulting.Rendering.Svg/NamespaceDoc.cs new file mode 100644 index 0000000..dfd638d --- /dev/null +++ b/src/DemaConsulting.Rendering.Svg/NamespaceDoc.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Svg; + +/// +/// The SVG renderer tier: draws a placed LayoutTree (from the DemaConsulting.Rendering +/// model, laid out by DemaConsulting.Rendering.Layout) to a scalable SVG document. +/// +/// +/// +/// The single public type, , implements the +/// DemaConsulting.Rendering.Abstractions.IRenderer contract with zero external dependencies: +/// it is pure and stateless, emitting UTF-8 SVG 1.1 for each node and connector styled by the +/// supplied Theme. This is the final stage of the diagram pipeline for vector output. +/// +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs new file mode 100644 index 0000000..e69910d --- /dev/null +++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs @@ -0,0 +1,1180 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Globalization; +using System.Text; +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Svg; + +/// +/// Renders a to SVG format using zero external dependencies. +/// +/// +/// The renderer is pure and stateless: each call to builds a complete +/// SVG document from the supplied and writes it to the output stream. +/// No state is shared between calls. The produced SVG targets SVG 1.1 and is encoded in UTF-8. +/// +/// Node rendering: +/// - <rect> (with rx/ry for rounded +/// corners) + optional <text> for the label + <line> dividers +/// and <text> rows for each compartment; children rendered recursively. +/// - <path> with M/L/A commands for corner-radius- +/// aware bends; arrowheads as marker references defined in <defs>; optional +/// midpoint label as <text>. +/// - <text>. +/// - <rect> filled square with optional +/// <text> label offset away from the attached edge. +/// - → shape-specific SVG elements centered at the badge position +/// with optional <text> label. +/// - <rect> with optional rotated or horizontal +/// <text> label; children rendered recursively. +/// - <rect> header + dashed +/// <line> stem + <text> label. +/// - <rect> with white fill. +/// - → bordered <rect> cells with per-cell +/// <text> elements. +/// - All other node types are silently skipped for forward compatibility. +/// +/// Fill colors are derived from using modulo wrapping on +/// . +/// +public sealed class SvgRenderer : IRenderer +{ + /// Closing tag for an SVG marker element, indented to match the defs block. + private const string MarkerClose = " "; + + /// SVG text-anchor value for centered alignment. + private const string TextAnchorMiddle = "middle"; + + /// + public string MediaType => "image/svg+xml"; + + /// + public string DefaultExtension => ".svg"; + + /// + public IReadOnlyList FileExtensions => [".svg"]; + + /// + /// + /// Writes a complete SVG 1.1 document to . The canvas size is + /// taken from and . + /// All coordinates are expressed as doubles formatted to two decimal places. + /// + public void Render(LayoutTree layout, RenderOptions options, Stream output) + { + // Validate inputs — null arguments would produce silent failures + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(output); + + // Build the SVG document in memory then flush once to avoid partial writes + var sb = new StringBuilder(); + var theme = options.Theme; + + // Compute canvas dimensions, ensuring a minimum 1×1 canvas + var width = Math.Max(1.0, layout.Width * options.Scale); + var height = Math.Max(1.0, layout.Height * options.Scale); + + // Write SVG root element with explicit namespace and viewBox + sb.Append(CultureInfo.InvariantCulture, + $""""""); + sb.AppendLine(); + + // Write defs section with all line-end markers + WriteEndMarkerDefs(sb, theme); + + // Render all top-level nodes recursively (wires are drawn without their labels here) + foreach (var node in layout.Nodes) + { + RenderNode(sb, node, theme, options.Scale); + } + + // Final pass: draw every connector label on top of all wires and boxes, so that no later + // wire can draw over an earlier wire's label. Positions are computed up front so that labels + // that would collide (for example where two connectors cross) are spread apart. + var lines = CollectLines(layout.Nodes).ToList(); + var labelPositions = ConnectorLabelPlacer.Place(lines, theme.FontSizeBody); + foreach (var line in lines) + { + if (line.MidpointLabel is not null && labelPositions.TryGetValue(line, out var pos)) + { + RenderLineLabel(sb, line, theme, options.Scale, pos.X, pos.Y); + } + } + + // Close SVG root + sb.AppendLine(""); + + // Encode as UTF-8 and write to output stream + var bytes = Encoding.UTF8.GetBytes(sb.ToString()); + output.Write(bytes, 0, bytes.Length); + } + + /// + /// Writes the <defs> block containing all line-end (connector decoration) marker + /// definitions used by . + /// + /// + /// Markers defined: line-end-open-chevron (open two-stroke chevron drawn as a + /// <polyline> with no closing base edge), line-end-hollow-triangle (hollow + /// triangle), line-end-hollow-triangle-crossbar (hollow triangle with perpendicular + /// crossbar), line-end-filled-arrow (filled triangle), line-end-hollow-diamond + /// (hollow diamond), line-end-filled-diamond (filled diamond), line-end-circle + /// (hollow circle), and line-end-bar (perpendicular bar). Every coordinate is derived from + /// , so a geometry literal never appears twice and the PNG renderer + /// draws the identical shapes from the same source constants. + /// + /// String builder receiving the SVG markup. + /// Theme providing stroke color and width. + private static void WriteEndMarkerDefs(StringBuilder sb, Theme theme) + { + var stroke = theme.StrokeColor; + var sw = F(theme.StrokeWidth); + var bg = theme.BackgroundColor; + var trianglePoints = MarkerPoints( + NotationMetrics.TriangleVertices(), NotationMetrics.EndMarkerRefX, NotationMetrics.EndMarkerHalfWidth); + var diamondPoints = MarkerPoints( + NotationMetrics.DiamondVertices(), NotationMetrics.DiamondRefX, NotationMetrics.DiamondHalfWidth); + + sb.AppendLine(" "); + + // Open-chevron marker — OPEN two-stroke chevron (polyline with no closing base edge), drawn + // from the same triangle vertices as the hollow triangle but left open along the line. + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.AppendLine(MarkerClose); + + // Hollow-triangle marker — closed hollow triangle pointing along the line direction; filled + // with the background color so the connector line does not show through the unfilled interior. + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.AppendLine(MarkerClose); + + // Filled-arrow marker — solid triangle pointing along the line direction + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.AppendLine(MarkerClose); + + // Hollow-triangle-with-crossbar marker — hollow triangle + perpendicular crossbar on the shaft. + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.AppendLine(MarkerClose); + + // Hollow-diamond marker — open four-point diamond straddling the line end + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.AppendLine(MarkerClose); + + // Filled-diamond marker — solid four-point diamond straddling the line end + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.AppendLine(MarkerClose); + + // Circle marker — open circle whose near edge sits at the line endpoint + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.AppendLine(MarkerClose); + + // Bar marker — perpendicular line centered on the line endpoint + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.AppendLine(MarkerClose); + + // Auto-sizing background for text drawn over lines (e.g. message and guard labels), painted in + // the theme background so it occludes the connector line consistently under any theme. + // The filter region defaults to the text bounding box; the small negative inset adds padding. + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.AppendLine(CultureInfo.InvariantCulture, $""" """); + sb.AppendLine(""" """); + sb.AppendLine(" "); + + sb.AppendLine(" "); + } + + /// + /// Builds an SVG points string from tip-relative marker vertices by mapping each vertex to + /// marker-box coordinates: boxX = refAlong - Along and boxY = refAcross + Across. + /// + /// Tip-relative marker vertices in draw order. + /// Along-line position of the marker box anchor (the SVG refX). + /// Across-line position of the marker box anchor (the SVG refY). + /// An SVG points attribute value, for example "0 0, 10 3.5, 0 7". + private static string MarkerPoints(IReadOnlyList vertices, double refAlong, double refAcross) + { + var sb = new StringBuilder(); + for (var i = 0; i < vertices.Count; i++) + { + if (i > 0) + { + sb.Append(", "); + } + + sb.Append(CultureInfo.InvariantCulture, + $"{M(refAlong - vertices[i].Along)} {M(refAcross + vertices[i].Across)}"); + } + + return sb.ToString(); + } + + /// + /// Formats a notation-metric coordinate for an SVG marker definition: rounded to six decimals + /// (to absorb floating-point noise in derived constants such as the crossbar position) and emitted + /// with the minimal invariant-culture representation, so a derived value reproduces the historical + /// literal byte-for-byte (for example 10, 3.5, -0.05). + /// + /// The notation-metric coordinate. + /// The formatted number. + private static string M(double value) => + Math.Round(value, 6).ToString(CultureInfo.InvariantCulture); + + /// + /// Dispatches a single to the appropriate typed render method. + /// Unknown concrete types are silently skipped so that future node types do not break + /// existing callers. + /// + /// String builder receiving the SVG markup. + /// The node to render. + /// Visual theme providing colors and dimensions. + /// Uniform scale factor applied to all coordinates. + private static void RenderNode(StringBuilder sb, LayoutNode node, Theme theme, double scale) + { + switch (node) + { + case LayoutBox box: + RenderBox(sb, box, theme, scale); + break; + + case LayoutLine line: + RenderLine(sb, line, theme, scale); + break; + + case LayoutLabel label: + RenderLabel(sb, label, theme, scale); + break; + + case LayoutPort port: + RenderPort(sb, port, theme, scale); + break; + + case LayoutBadge badge: + RenderBadge(sb, badge, theme, scale); + break; + + case LayoutBand band: + RenderBand(sb, band, theme, scale); + break; + + case LayoutLifeline lifeline: + RenderLifeline(sb, lifeline, theme, scale); + break; + + case LayoutActivation activation: + RenderActivation(sb, activation, theme, scale); + break; + + case LayoutGrid grid: + RenderGrid(sb, grid, theme, scale); + break; + + default: + // Skip unknown node types for forward compatibility + break; + } + } + + /// + /// Renders a as a <rect> with rx/ry + /// attributes for rounded corners, an optional centered <text> label, + /// horizontal divider <line> elements and text rows for each compartment, + /// then recursively renders its children. + /// + /// String builder receiving the SVG markup. + /// The box node to render. + /// Visual theme providing fill colors, stroke, and font size. + /// Uniform scale factor. + private static void RenderBox(StringBuilder sb, LayoutBox box, Theme theme, double scale) + { + // Derive fill color from theme using depth modulo wrapping + var fillColor = theme.DepthFillColors[box.Depth % theme.DepthFillColors.Count]; + + // Draw the box outline (shape-specific) + RenderBoxOutline(sb, box, theme, fillColor, scale); + + // Draw the keyword and label in the title area + RenderBoxTitle(sb, box, theme, scale); + + // Render compartments below the label area + if (box.Compartments.Count > 0) + { + RenderBoxCompartments(sb, box, theme, scale); + } + + // Render children recursively + foreach (var child in box.Children) + { + RenderNode(sb, child, theme, scale); + } + } + + /// + /// Renders the outline (border and fill) of a , selecting the path + /// geometry based on . + /// + /// String builder receiving the SVG markup. + /// The box whose outline is drawn. + /// Visual theme providing stroke settings and corner radius. + /// Resolved fill color for the box interior. + /// Uniform scale factor. + private static void RenderBoxOutline(StringBuilder sb, LayoutBox box, Theme theme, string fillColor, double scale) + { + var x = box.X * scale; + var y = box.Y * scale; + var w = box.Width * scale; + var h = box.Height * scale; + + switch (box.Shape) + { + case BoxShape.Folder: + RenderFolderOutline(sb, box, theme, fillColor, scale); + break; + + case BoxShape.Note: + RenderNoteOutline(sb, box, theme, fillColor, scale); + break; + + case BoxShape.RoundedRectangle: + var cornerStr = theme.LineCornerRadius > 0 + ? $" rx=\"{F(NotationMetrics.RoundedRectRadius(theme) * scale)}\" ry=\"{F(NotationMetrics.RoundedRectRadius(theme) * scale)}\"" + : string.Empty; + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + break; + + default: + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + break; + } + } + + /// + /// Renders a folder-shaped outline (a tab at the top-left above a full-width body), + /// used for package nodes. + /// + private static void RenderFolderOutline(StringBuilder sb, LayoutBox box, Theme theme, string fillColor, double scale) + { + var tabHeight = BoxMetrics.FolderTabHeight(theme); + var tabWidth = Math.Min( + box.Width * NotationMetrics.FolderTabMaxWidthFraction, + Math.Max( + NotationMetrics.FolderTabMinWidth, + (box.Label?.Length ?? 4) * theme.FontSizeBody * NotationMetrics.FolderLabelCharWidthFactor + 2.0 * theme.LabelPadding)); + + var x = box.X * scale; + var yTab = box.Y * scale; + var yBody = (box.Y + tabHeight) * scale; + var xTabRight = (box.X + tabWidth) * scale; + var xRight = (box.X + box.Width) * scale; + var yBottom = (box.Y + box.Height) * scale; + + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + } + + /// + /// Renders a note-shaped outline (a rectangle with a folded-down top-right corner), + /// used for documentation and comment nodes. + /// + private static void RenderNoteOutline(StringBuilder sb, LayoutBox box, Theme theme, string fillColor, double scale) + { + var fold = Math.Min(box.Width, box.Height) * NotationMetrics.NoteFoldFraction; + fold = Math.Min(fold, NotationMetrics.NoteFoldMaxSize); + + var x = box.X * scale; + var y = box.Y * scale; + var xRight = (box.X + box.Width) * scale; + var xFold = (box.X + box.Width - fold) * scale; + var yFold = (box.Y + fold) * scale; + var yBottom = (box.Y + box.Height) * scale; + + // Main body with the top-right corner cut + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + + // The folded corner triangle + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + } + + /// + /// Renders the optional keyword line and bold name label in the title area of a box. + /// + /// String builder receiving the SVG markup. + /// Box whose title is rendered. + /// Visual theme providing font sizes and padding. + /// Uniform scale factor. + private static void RenderBoxTitle(StringBuilder sb, LayoutBox box, Theme theme, double scale) + { + var centerX = (box.X + box.Width / 2.0) * scale; + var cursorY = box.Y + theme.LabelPadding; + + // Keyword line (smaller, italic, guillemet-wrapped) above the name + if (box.Keyword != null) + { + var kwY = (cursorY + theme.FontSizeBody / 2.0) * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml("\u00AB" + box.Keyword + "\u00BB")}"""); + sb.AppendLine(); + cursorY += theme.FontSizeBody + theme.LabelPadding; + } + + // Bold name label — only constrain width when the text would actually overflow the box, + // so short labels render at their natural size instead of being stretched to fill. + if (box.Label != null) + { + var textY = (cursorY + theme.FontSizeTitle / 2.0) * scale; + var availableWidth = box.Width - (2 * theme.LabelPadding); + var fit = FitTextLength(box.Label, theme.FontSizeTitle, availableWidth, scale); + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(box.Label)}"""); + sb.AppendLine(); + } + } + + /// + /// Returns an SVG textLength/lengthAdjust attribute fragment that constrains text + /// to only when the text's estimated natural width exceeds it; + /// otherwise returns an empty string so the text renders at its natural width (no stretching). + /// + /// The text to be rendered. + /// Unscaled font size of the text. + /// Unscaled width available for the text. + /// Uniform scale factor. + /// A leading-space attribute fragment, or an empty string when no constraint is needed. + private static string FitTextLength(string text, double fontSize, double availableWidth, double scale) + { + // Rough average glyph-width estimate; matches the layout engine's sizing factor. + const double GlyphWidthFactor = 0.6; + var estimatedWidth = text.Length * fontSize * GlyphWidthFactor; + if (availableWidth <= 0 || estimatedWidth <= availableWidth) + { + return string.Empty; + } + + return $""" textLength="{F(availableWidth * scale)}" lengthAdjust="spacingAndGlyphs" """.TrimEnd(); + } + + /// + /// Renders the compartments of a below the title area as SVG + /// <line> dividers and <text> elements. + /// + /// String builder receiving the SVG markup. + /// Box whose compartments are rendered. + /// Visual theme providing font sizes, padding, and stroke settings. + /// Uniform scale factor. + private static void RenderBoxCompartments(StringBuilder sb, LayoutBox box, Theme theme, double scale) + { + // Compartments start below the title area (keyword + label), computed via shared metrics + var labelAreaHeight = BoxMetrics.TitleAreaHeight(theme, box.Label != null, box.Keyword != null); + var compartmentY = box.Y + labelAreaHeight; + + foreach (var compartment in box.Compartments) + { + // Full-width horizontal divider at the top of this compartment + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + + // Draw optional bold compartment title + if (compartment.Title != null) + { + var titleX = (box.X + theme.LabelPadding) * scale; + var titleY = (compartmentY + theme.LabelPadding + theme.FontSizeBody / 2.0) * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(compartment.Title)}"""); + sb.AppendLine(); + compartmentY += theme.LabelPadding + theme.FontSizeBody + theme.LabelPadding; + } + + // Draw each body row with body font size and left-aligned indent + foreach (var row in compartment.Rows) + { + var rowX = (box.X + theme.LabelPadding) * scale; + var rowY = (compartmentY + theme.LabelPadding + theme.FontSizeBody / 2.0) * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(row)}"""); + sb.AppendLine(); + compartmentY += theme.LabelPadding + theme.FontSizeBody; + } + + // Bottom gap so the last row clears the next compartment divider. + compartmentY += theme.LabelPadding; + } + } + + /// + /// Renders a as an SVG <path> element with + /// arc-at-bend corner rounding, optional arrowhead marker references, optional dashing, + /// and an optional midpoint label. + /// + /// + /// When is zero, each interior waypoint is + /// connected with a plain L command. When the radius is positive, each interior + /// waypoint is replaced with a shortened incoming L command and an A + /// (arc) command whose sweep direction is derived from the cross product of the incoming + /// and outgoing direction vectors. + /// + /// String builder receiving the SVG markup. + /// The line node to render. + /// Visual theme providing stroke color and width. + /// Uniform scale factor. + private static void RenderLine(StringBuilder sb, LayoutLine line, Theme theme, double scale) + { + // Lines with fewer than 2 waypoints cannot be drawn + if (line.Waypoints.Count < 2) + { + return; + } + + // Build SVG path data with optional arc-at-bend corner rounding. The final corner before each + // end is clamped so the rounded arc completes at least the marker's along-line length before + // the endpoint, keeping the decoration on a clean straight approach. + var pathData = BuildLinePath( + line.Waypoints, theme.LineCornerRadius, scale, line.SourceEnd, line.TargetEnd); + + // Resolve line-end marker attribute strings + var markerStart = line.SourceEnd switch + { + EndMarkerStyle.OpenChevron => " marker-start=\"url(#line-end-open-chevron)\"", + EndMarkerStyle.HollowTriangle => " marker-start=\"url(#line-end-hollow-triangle)\"", + EndMarkerStyle.HollowTriangleCrossbar => " marker-start=\"url(#line-end-hollow-triangle-crossbar)\"", + EndMarkerStyle.FilledArrow => " marker-start=\"url(#line-end-filled-arrow)\"", + EndMarkerStyle.HollowDiamond => " marker-start=\"url(#line-end-hollow-diamond)\"", + EndMarkerStyle.FilledDiamond => " marker-start=\"url(#line-end-filled-diamond)\"", + EndMarkerStyle.Circle => " marker-start=\"url(#line-end-circle)\"", + EndMarkerStyle.Bar => " marker-start=\"url(#line-end-bar)\"", + _ => string.Empty + }; + var markerEnd = line.TargetEnd switch + { + EndMarkerStyle.OpenChevron => " marker-end=\"url(#line-end-open-chevron)\"", + EndMarkerStyle.HollowTriangle => " marker-end=\"url(#line-end-hollow-triangle)\"", + EndMarkerStyle.HollowTriangleCrossbar => " marker-end=\"url(#line-end-hollow-triangle-crossbar)\"", + EndMarkerStyle.FilledArrow => " marker-end=\"url(#line-end-filled-arrow)\"", + EndMarkerStyle.HollowDiamond => " marker-end=\"url(#line-end-hollow-diamond)\"", + EndMarkerStyle.FilledDiamond => " marker-end=\"url(#line-end-filled-diamond)\"", + EndMarkerStyle.Circle => " marker-end=\"url(#line-end-circle)\"", + EndMarkerStyle.Bar => " marker-end=\"url(#line-end-bar)\"", + _ => string.Empty + }; + + // Determine stroke dash pattern for non-solid lines + var dashArray = line.LineStyle switch + { + LineStyle.Dashed => " stroke-dasharray=\"6 3\"", + LineStyle.Dotted => " stroke-dasharray=\"2 2\"", + _ => string.Empty + }; + + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + + // Note: the midpoint label is intentionally NOT drawn here. It is drawn in a final pass + // (see RenderLineLabel) so that no later wire can draw over an earlier wire's label. + } + + /// + /// Renders a line's optional midpoint label as a centered text element with an auto-sizing white + /// background (via the label-bg filter). Called in a final pass after all wires and boxes + /// are drawn so labels are never drawn over by another wire. + /// + /// String builder receiving the SVG markup. + /// The line whose label is rendered. + /// Visual theme providing font and color settings. + /// Uniform scale factor. + /// Pre-computed label centre X in logical pixels. + /// Pre-computed label centre Y in logical pixels. + private static void RenderLineLabel(StringBuilder sb, LayoutLine line, Theme theme, double scale, double midX, double midY) + { + if (line.MidpointLabel is null) + { + return; + } + + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(line.MidpointLabel)}"""); + sb.AppendLine(); + } + + /// Recursively collects all nodes from a node tree. + /// Top-level nodes to walk. + /// Every line node, including those nested inside boxes or bands. + private static IEnumerable CollectLines(IReadOnlyList nodes) + { + foreach (var node in nodes) + { + switch (node) + { + case LayoutLine line: + yield return line; + break; + + case LayoutBox box: + foreach (var inner in CollectLines(box.Children)) + { + yield return inner; + } + + break; + + case LayoutBand band: + foreach (var inner in CollectLines(band.Children)) + { + yield return inner; + } + + break; + + default: + break; + } + } + } + + /// + /// Builds the SVG path d attribute string for a polyline, applying arc-at-bend + /// corner rounding for each interior waypoint when is + /// greater than zero. + /// + /// + /// For each interior waypoint the incoming segment is shortened by + /// and an SVG arc (A command) bridges the gap. + /// The arc sweep direction is determined by the cross product of the incoming and outgoing + /// direction vectors: positive cross product (clockwise turn in SVG screen space) uses + /// sweep-flag 1; negative uses sweep-flag 0. The radius is clamped to half the shorter + /// adjacent segment so the arc never overshoots. + /// + /// Ordered waypoints; must contain at least 2 entries. + /// Corner rounding radius in logical pixels; 0 disables arcs. + /// Uniform scale factor. + /// End-marker style at the first waypoint (source endpoint). + /// End-marker style at the last waypoint (target endpoint). + /// SVG path data string starting with M. + private static string BuildLinePath( + IReadOnlyList waypoints, + double cornerRadius, + double scale, + EndMarkerStyle sourceEnd, + EndMarkerStyle targetEnd) + { + var sb = new StringBuilder(); + var first = waypoints[0]; + sb.Append(CultureInfo.InvariantCulture, $"M {F(first.X * scale)} {F(first.Y * scale)}"); + + if (cornerRadius <= 0) + { + // No corner rounding: plain M/L path + for (var i = 1; i < waypoints.Count; i++) + { + var wp = waypoints[i]; + sb.Append(CultureInfo.InvariantCulture, $" L {F(wp.X * scale)} {F(wp.Y * scale)}"); + } + + return sb.ToString(); + } + + // Arc-at-bends: replace each interior waypoint with a shortened L + arc A command + for (var i = 1; i < waypoints.Count; i++) + { + var cur = waypoints[i]; + + var isInterior = i < waypoints.Count - 1; + if (!isInterior) + { + // Last waypoint: plain line to the endpoint + sb.Append(CultureInfo.InvariantCulture, $" L {F(cur.X * scale)} {F(cur.Y * scale)}"); + continue; + } + + var prev = waypoints[i - 1]; + var next = waypoints[i + 1]; + + // Incoming direction from prev to cur + var inDx = cur.X - prev.X; + var inDy = cur.Y - prev.Y; + var inLen = Math.Sqrt(inDx * inDx + inDy * inDy); + + // Outgoing direction from cur to next + var outDx = next.X - cur.X; + var outDy = next.Y - cur.Y; + var outLen = Math.Sqrt(outDx * outDx + outDy * outDy); + + if (inLen < 0.001 || outLen < 0.001) + { + // Degenerate segment: fall back to a plain line command + sb.Append(CultureInfo.InvariantCulture, $" L {F(cur.X * scale)} {F(cur.Y * scale)}"); + continue; + } + + // Normalize both direction vectors + var inNx = inDx / inLen; + var inNy = inDy / inLen; + var outNx = outDx / outLen; + var outNy = outDy / outLen; + + // Clamp radius so the arc never overshoots either adjacent segment + var r = Math.Min(cornerRadius, Math.Min(inLen / 2.0, outLen / 2.0)); + + // Decoration-aware final-corner clamp: keep the rounded corner from intruding into an + // end-marker decoration. The first interior corner must leave at least the source marker's + // along-line length of straight run after the source endpoint; the last interior corner + // must leave at least the target marker's along-line length of straight run before the + // target endpoint. For forward edges (whose approaches are already long) this is a no-op. + if (i == 1) + { + r = Math.Min(r, inLen - NotationMetrics.AlongLineLength(sourceEnd)); + } + + if (i == waypoints.Count - 2) + { + r = Math.Min(r, outLen - NotationMetrics.AlongLineLength(targetEnd)); + } + + if (r <= 0.0) + { + // The decoration consumes the whole approach: fall back to a plain line (no arc). + sb.Append(CultureInfo.InvariantCulture, $" L {F(cur.X * scale)} {F(cur.Y * scale)}"); + continue; + } + + // Endpoint of the shortened incoming segment (just before the corner) + var shortEndX = cur.X - inNx * r; + var shortEndY = cur.Y - inNy * r; + + // Start of the outgoing segment after the arc (just past the corner) + var shortStartX = cur.X + outNx * r; + var shortStartY = cur.Y + outNy * r; + + // Cross product z-component determines clockwise vs counter-clockwise arc in SVG + // (positive cross = clockwise screen turn = sweep-flag 1) + var cross = inNx * outNy - inNy * outNx; + var sweep = cross > 0 ? 1 : 0; + + sb.Append(CultureInfo.InvariantCulture, + $" L {F(shortEndX * scale)} {F(shortEndY * scale)}"); + sb.Append(CultureInfo.InvariantCulture, + $" A {F(r * scale)} {F(r * scale)} 0 0 {sweep} {F(shortStartX * scale)} {F(shortStartY * scale)}"); + } + + return sb.ToString(); + } + + /// + /// Renders a as an SVG <text> element with + /// text-anchor derived from . + /// + /// String builder receiving the SVG markup. + /// The label node to render. + /// Visual theme providing font and color settings. + /// Uniform scale factor. + private static void RenderLabel(StringBuilder sb, LayoutLabel label, Theme theme, double scale) + { + var x = label.X * scale; + var y = label.Y * scale; + var anchor = label.Align switch + { + TextAlign.Center => TextAnchorMiddle, + TextAlign.Right => "end", + _ => "start" + }; + var fontWeight = label.Weight == FontWeight.Bold ? "bold" : "normal"; + var fontStyle = label.Style == FontStyle.Italic ? "italic" : "normal"; + + // Only constrain width when the text would overflow MaxWidth (no stretching of short text). + var textLengthAttr = FitTextLength(label.Text, label.FontSize, label.MaxWidth, scale); + + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(label.Text)}"""); + sb.AppendLine(); + } + + /// + /// Renders a as a filled 8×8 <rect> centered at + /// the port position, with an optional <text> label offset away from the + /// attached edge. + /// + /// String builder receiving the SVG markup. + /// The port node to render. + /// Visual theme providing stroke color and font settings. + /// Uniform scale factor. + private static void RenderPort(StringBuilder sb, LayoutPort port, Theme theme, double scale) + { + // Port square: filled with the stroke color, sized from NotationMetrics. + var rx = (port.CentreX - NotationMetrics.PortHalfSize) * scale; + var ry = (port.CentreY - NotationMetrics.PortHalfSize) * scale; + var rs = NotationMetrics.PortSize * scale; + + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + + // Optional label offset away from the attached edge + if (port.Label != null) + { + var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; + var (labelX, labelY, anchor) = port.Side switch + { + PortSide.Top => (port.CentreX, port.CentreY - offset, TextAnchorMiddle), + PortSide.Bottom => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, TextAnchorMiddle), + PortSide.Left => (port.CentreX - offset, port.CentreY + theme.FontSizeBody / 2.0, "end"), + _ => (port.CentreX + offset, port.CentreY + theme.FontSizeBody / 2.0, "start") + }; + + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(port.Label)}"""); + sb.AppendLine(); + } + } + + /// + /// Renders a as shape-specific SVG elements centered at the + /// badge position, with an optional <text> label to the right. + /// + /// String builder receiving the SVG markup. + /// The badge node to render. + /// Visual theme providing stroke color, width, and font settings. + /// Uniform scale factor. + private static void RenderBadge(StringBuilder sb, LayoutBadge badge, Theme theme, double scale) + { + var cx = badge.CentreX * scale; + var cy = badge.CentreY * scale; + var r = badge.Size / 2.0 * scale; + var sw = F(theme.StrokeWidth); + + // Draw the badge shape centered at (cx, cy) within bounding radius r + switch (badge.Shape) + { + case BadgeShape.FilledCircle: + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + break; + + case BadgeShape.Bullseye: + // Outer filled circle + white inner circle for the ring effect + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + break; + + case BadgeShape.Diamond: + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + break; + + case BadgeShape.HorizontalBar: + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + break; + + case BadgeShape.VerticalBar: + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + break; + + default: + // Unknown badge shapes are skipped for forward compatibility + break; + } + + // Optional label to the right of the bounding circle + if (badge.Label != null) + { + var labelX = (badge.CentreX + badge.Size / 2.0 + theme.LabelPadding) * scale; + var labelY = (badge.CentreY + theme.FontSizeBody / 2.0) * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(badge.Label)}"""); + sb.AppendLine(); + } + } + + /// + /// Renders a as a <rect> with a depth-0 fill, + /// an optional label (rotated for Horizontal orientation, horizontal for Vertical), + /// then recursively renders its children. + /// + /// String builder receiving the SVG markup. + /// The band node to render. + /// Visual theme providing fill colors, stroke, and font settings. + /// Uniform scale factor. + private static void RenderBand(StringBuilder sb, LayoutBand band, Theme theme, double scale) + { + var x = band.X * scale; + var y = band.Y * scale; + var w = band.Width * scale; + var h = band.Height * scale; + var fillColor = theme.DepthFillColors[0]; + + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + + // Draw the optional label; Horizontal → rotated 90° CCW on left edge, Vertical → top + if (band.Label != null) + { + if (band.Orientation == BandOrientation.Horizontal) + { + // Label center on the left edge strip, rotated 90° CCW + var labelCx = (band.X + theme.LabelPadding + theme.FontSizeBody / 2.0) * scale; + var labelCy = (band.Y + band.Height / 2.0) * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(band.Label)}"""); + sb.AppendLine(); + } + else + { + // Horizontal label at the top of the band + var textX = (band.X + band.Width / 2.0) * scale; + var textY = (band.Y + theme.LabelPadding + theme.FontSizeBody / 2.0) * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(band.Label)}"""); + sb.AppendLine(); + } + } + + // Render children recursively + foreach (var child in band.Children) + { + RenderNode(sb, child, theme, scale); + } + } + + /// + /// Renders a as a header <rect> with a + /// centered label, followed by a dashed <line> stem running from the + /// bottom of the header to . + /// + /// String builder receiving the SVG markup. + /// The lifeline node to render. + /// Visual theme providing colors, font, and stroke settings. + /// Uniform scale factor. + private static void RenderLifeline(StringBuilder sb, LayoutLifeline lifeline, Theme theme, double scale) + { + // Header box: centered at CentreX, top edge at TopY + var headerLeft = lifeline.CentreX - lifeline.HeaderWidth / 2.0; + var hx = headerLeft * scale; + var hy = lifeline.TopY * scale; + var hw = lifeline.HeaderWidth * scale; + var hh = lifeline.HeaderHeight * scale; + var fillColor = theme.DepthFillColors[0]; + + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + + // Centered label within the header box + var textX = lifeline.CentreX * scale; + var textY = (lifeline.TopY + lifeline.HeaderHeight / 2.0) * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(lifeline.Label)}"""); + sb.AppendLine(); + + // Dashed vertical stem from the bottom of the header to BottomY + var stemX = lifeline.CentreX * scale; + var stemTopY = (lifeline.TopY + lifeline.HeaderHeight) * scale; + var stemBottomY = lifeline.BottomY * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + } + + /// + /// Renders a as a narrow white-filled + /// <rect> with a stroke border, centered at + /// . + /// + /// + /// The bar width is Theme.LabelPadding * 2 so it scales proportionally with + /// the diagram's text padding. + /// + /// String builder receiving the SVG markup. + /// The activation node to render. + /// Visual theme providing stroke settings and padding. + /// Uniform scale factor. + private static void RenderActivation(StringBuilder sb, LayoutActivation activation, Theme theme, double scale) + { + // Bar width = LabelPadding * 2, centered at CentreX + var halfWidth = theme.LabelPadding; + var ax = (activation.CentreX - halfWidth) * scale; + var ay = activation.TopY * scale; + var aw = halfWidth * 2.0 * scale; + var ah = (activation.BottomY - activation.TopY) * scale; + + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + } + + /// + /// Renders a as a bordered table. Header rows are filled with + /// the depth-1 theme color; body rows use the depth-0 color. Each cell contains a + /// <text> element aligned per . + /// + /// String builder receiving the SVG markup. + /// The grid node to render. + /// Visual theme providing fill colors, stroke, and font settings. + /// Uniform scale factor. + private static void RenderGrid(StringBuilder sb, LayoutGrid grid, Theme theme, double scale) + { + // Header rows use depth-1 color; body rows use depth-0 color + var headerFill = theme.DepthFillColors[1 % theme.DepthFillColors.Count]; + var bodyFill = theme.DepthFillColors[0]; + + var currentY = grid.Y; + foreach (var row in grid.Rows) + { + // Row height = maximum cell height in this row + var rowHeight = 0.0; + foreach (var cell in row.Cells) + { + rowHeight = Math.Max(rowHeight, cell.Height); + } + + var fillColor = row.IsHeader ? headerFill : bodyFill; + var currentX = grid.X; + + foreach (var cell in row.Cells) + { + var cx = currentX * scale; + var cy = currentY * scale; + var cw = cell.Width * scale; + var ch = rowHeight * scale; + + // Cell background and border + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); + + // Cell text properties are computed in a helper to stay within complexity limits + var (anchor, textX, fontWeightAttr) = GetCellTextProperties(cell, currentX, theme, row.IsHeader); + var textY = currentY + rowHeight / 2.0; + + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(cell.Text)}"""); + sb.AppendLine(); + + currentX += cell.Width; + } + + currentY += rowHeight; + } + } + + /// + /// Computes the SVG text-anchor, horizontal text position, and font-weight attribute for a + /// single grid cell. Extracted from to reduce its cognitive complexity. + /// + /// The grid cell whose alignment drives the computed values. + /// Logical X origin of the cell within the grid. + /// Visual theme providing label padding. + /// + /// when the cell belongs to a header row; controls font weight. + /// + /// + /// A tuple of (Anchor, TextX, FontWeightAttr) ready for SVG output. + /// + private static (string Anchor, double TextX, string FontWeightAttr) GetCellTextProperties( + LayoutGridCell cell, + double cellLeft, + Theme theme, + bool isHeader) + { + var anchor = cell.Align switch + { + TextAlign.Center => TextAnchorMiddle, + TextAlign.Right => "end", + _ => "start" + }; + var textX = cell.Align switch + { + TextAlign.Center => cellLeft + cell.Width / 2.0, + TextAlign.Right => cellLeft + cell.Width - theme.LabelPadding, + _ => cellLeft + theme.LabelPadding + }; + var fontWeightAttr = isHeader ? " font-weight=\"bold\"" : string.Empty; + return (anchor, textX, fontWeightAttr); + } + + /// + /// Formats a double to two decimal places using invariant culture. + /// + /// Value to format. + /// String representation with exactly two decimal digits. + private static string F(double value) => + value.ToString("F2", CultureInfo.InvariantCulture); + + /// + /// Escapes XML special characters in a string for safe embedding in SVG text content. + /// + /// Raw text to escape. + /// XML-safe text with <, >, and & replaced by + /// their entity equivalents. + private static string EscapeXml(string text) => + text.Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal); +} + diff --git a/src/DemaConsulting.Rendering/DemaConsulting.Rendering.csproj b/src/DemaConsulting.Rendering/DemaConsulting.Rendering.csproj index 6c36dd9..6fa0370 100644 --- a/src/DemaConsulting.Rendering/DemaConsulting.Rendering.csproj +++ b/src/DemaConsulting.Rendering/DemaConsulting.Rendering.csproj @@ -1,7 +1,7 @@ - netstandard2.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 latest enable enable @@ -11,7 +11,7 @@ 0.0.0 DEMA Consulting DEMA Consulting - Rendering library for DEMA Consulting + General-purpose diagram layout model and intermediate representation for .NET MIT https://github.com/demaconsulting/Rendering https://github.com/demaconsulting/Rendering @@ -32,6 +32,7 @@ true true + true true true latest @@ -51,6 +52,7 @@ + diff --git a/src/DemaConsulting.Rendering/Demo.cs b/src/DemaConsulting.Rendering/Demo.cs deleted file mode 100644 index 7bef8da..0000000 --- a/src/DemaConsulting.Rendering/Demo.cs +++ /dev/null @@ -1,94 +0,0 @@ -namespace DemaConsulting.Rendering; - -/// -/// Demonstration class for the template library. -/// -/// -/// This class serves as the sole software unit in the Rendering library, -/// demonstrating DEMA Consulting patterns for structure, documentation, and testing. -/// Instances are immutable after construction and are therefore thread-safe. -/// -public class Demo -{ - /// - /// The greeting prefix used when no custom prefix is specified. - /// - public const string DefaultPrefix = "Hello"; - - /// - /// The prefix prepended to every greeting produced by this instance. - /// - private readonly string _prefix; - - /// - /// Initializes a new instance of the class with the default prefix. - /// - /// - /// Provides a zero-argument construction path for callers who want the standard greeting - /// without specifying a prefix. Delegates to so that - /// validation and storage logic remain in one place. - /// - public Demo() - : this(DefaultPrefix) - { - } - - /// - /// Initializes a new instance of the class with a custom prefix. - /// - /// The prefix to use in greetings. - /// - /// Thrown when is . - /// - /// - /// Thrown when is an empty string. - /// - /// - /// Validation is performed at construction time so that a misconfigured instance is - /// refused immediately rather than silently producing malformed output on the first - /// method call. This is the canonical constructor; delegates - /// to this overload with . - /// - public Demo(string prefix) - { - // Validate that the prefix is non-null and non-empty before storing it - ArgumentException.ThrowIfNullOrEmpty(prefix); - _prefix = prefix; - } - - /// - /// Gets the greeting prefix used by this instance. - /// - /// - /// Exposed so that callers can inspect the configured prefix without maintaining an - /// independent copy of the value passed at construction time. - /// - public string Prefix => _prefix; - - /// - /// Returns a greeting message that combines the instance prefix with the given name. - /// - /// The name to include in the greeting. - /// - /// A greeting string in the format {prefix}, {name}!, - /// for example Hello, World!. - /// - /// - /// Thrown when is . - /// - /// - /// Thrown when is an empty string. - /// - /// - /// This method is stateless and has no side effects. The output depends only on - /// the prefix stored at construction time and the argument. - /// - public string DemoMethod(string name) - { - // Validate that the name is non-null and non-empty before building the greeting - ArgumentException.ThrowIfNullOrEmpty(name); - - // Combine the prefix and name into the standard greeting format - return $"{_prefix}, {name}!"; - } -} diff --git a/src/DemaConsulting.Rendering/Graph/LayoutGraph.cs b/src/DemaConsulting.Rendering/Graph/LayoutGraph.cs new file mode 100644 index 0000000..1ffe47c --- /dev/null +++ b/src/DemaConsulting.Rendering/Graph/LayoutGraph.cs @@ -0,0 +1,124 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Collections.ObjectModel; + +namespace DemaConsulting.Rendering; + +/// +/// The unplaced input to a layout algorithm: a set of sized boxes and +/// directed connections. A layout algorithm consumes a +/// plus and produces a placed +/// . The graph itself is an , so graph-wide +/// options may be attached directly to it. +/// +/// +/// +/// A is a container scope. The instance created directly by a +/// caller is the top-level (root) container, mirroring the Eclipse Layout Kernel (ELK) root +/// ElkNode. The same container type is reused at every level of nesting: a +/// becomes a compound/container node by populating its +/// graph, so hierarchy is expressed by recursion rather +/// than by a distinct type. +/// +/// +/// Identifiers are unique per container. Each graph enforces its own node- and +/// edge-identifier uniqueness, so an identifier may be reused freely in different scopes but not +/// twice within one scope. +/// +/// +/// Contained-edge / lowest-common-ancestor (LCA) convention. An edge belongs in +/// the container at or above the lowest common ancestor of its two endpoints. An edge whose +/// endpoints share this graph as their nearest common container is added here; a +/// cross-container edge — one connecting nodes that live in different descendant +/// containers — is likewise added to an ancestor container (this root, or the LCA) while its +/// and reference the +/// descendant nodes directly. No separate cross-container edge type is required, and no +/// membership validation forbids these references; a hierarchical layout engine resolves the +/// routing in the owning container's coordinate space. +/// +/// +public sealed class LayoutGraph : PropertyHolder +{ + private readonly List _nodes = []; + private readonly List _edges = []; + private readonly ReadOnlyCollection _nodesView; + private readonly ReadOnlyCollection _edgesView; + private readonly HashSet _nodeIds = new(StringComparer.Ordinal); + private readonly HashSet _edgeIds = new(StringComparer.Ordinal); + + /// + /// Initializes a new, empty container scope. + /// + public LayoutGraph() + { + _nodesView = _nodes.AsReadOnly(); + _edgesView = _edges.AsReadOnly(); + } + + /// Gets the nodes to be placed, in insertion order. The returned view is genuinely + /// read-only (a ); add nodes through . + public IReadOnlyList Nodes => _nodesView; + + /// Gets the directed edges connecting the nodes. The returned view is genuinely read-only + /// (a ); add edges through . + public IReadOnlyList Edges => _edgesView; + + /// + /// Creates a node, adds it to , and returns it for further configuration. + /// + /// Identifier unique within this graph. + /// Width of the node's bounding box in logical pixels. + /// Height of the node's bounding box in logical pixels. + /// The newly-created node. + /// Thrown when is . + /// + /// Thrown when is empty, or when a node with the same + /// already exists in this graph. + /// + public LayoutGraphNode AddNode(string id, double width, double height) + { + var node = new LayoutGraphNode(id, width, height); + if (!_nodeIds.Add(id)) + { + throw new ArgumentException($"A node with id '{id}' already exists in this graph.", nameof(id)); + } + + _nodes.Add(node); + return node; + } + + /// + /// Creates an edge between two nodes, adds it to , and returns it. + /// + /// + /// The endpoints need not be direct members of this graph: to express a cross-container edge, + /// add it to the container at or above the lowest common ancestor of its endpoints (per the + /// type-level contained-edge/LCA convention) and pass descendant nodes as + /// and/or . + /// + /// Identifier unique within this graph. + /// The node the edge originates from. + /// The node the edge terminates at. + /// The newly-created edge. + /// + /// Thrown when , , or is + /// . + /// + /// + /// Thrown when is empty, or when an edge with the same + /// already exists in this graph. + /// + public LayoutGraphEdge AddEdge(string id, LayoutGraphNode source, LayoutGraphNode target) + { + var edge = new LayoutGraphEdge(id, source, target); + if (!_edgeIds.Add(id)) + { + throw new ArgumentException($"An edge with id '{id}' already exists in this graph.", nameof(id)); + } + + _edges.Add(edge); + return edge; + } +} diff --git a/src/DemaConsulting.Rendering/Graph/LayoutGraphEdge.cs b/src/DemaConsulting.Rendering/Graph/LayoutGraphEdge.cs new file mode 100644 index 0000000..f7c309b --- /dev/null +++ b/src/DemaConsulting.Rendering/Graph/LayoutGraphEdge.cs @@ -0,0 +1,51 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// A directed edge in an input , connecting a source node to a target node. +/// The edge carries its own configuration via . +/// +public sealed class LayoutGraphEdge : PropertyHolder +{ + /// + /// Initializes a new instance of the class. + /// + /// Identifier unique within the owning graph. + /// The node the edge originates from. + /// The node the edge terminates at. + /// + /// Thrown when , , or is + /// . + /// + /// Thrown when is empty. + public LayoutGraphEdge(string id, LayoutGraphNode source, LayoutGraphNode target) + { + ArgumentException.ThrowIfNullOrEmpty(id); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(target); + Id = id; + Source = source; + Target = target; + } + + /// Gets the identifier, unique within the owning graph. + public string Id { get; } + + /// Gets the node the edge originates from. + public LayoutGraphNode Source { get; } + + /// Gets the node the edge terminates at. + public LayoutGraphNode Target { get; } + + /// Gets or sets the end-marker style drawn at the target end of the edge. + public EndMarkerStyle TargetEnd { get; set; } = EndMarkerStyle.None; + + /// Gets or sets the stroke style used to draw the edge. + public LineStyle LineStyle { get; set; } = LineStyle.Solid; + + /// Gets or sets an optional text label rendered at the midpoint of the edge. + public string? Label { get; set; } +} diff --git a/src/DemaConsulting.Rendering/Graph/LayoutGraphNode.cs b/src/DemaConsulting.Rendering/Graph/LayoutGraphNode.cs new file mode 100644 index 0000000..c05400a --- /dev/null +++ b/src/DemaConsulting.Rendering/Graph/LayoutGraphNode.cs @@ -0,0 +1,133 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// A node in an input : a sized box that a layout algorithm places. The +/// node carries its own configuration via , allowing per-node overrides +/// of algorithm options. A node may additionally act as a container of a nested child +/// subgraph, making the input model hierarchical. +/// +/// +/// +/// Modelled after the Eclipse Layout Kernel (ELK) ElkNode: a node is either a +/// leaf (no children) or a container (compound node) that owns a child +/// subgraph of nested nodes and the edges contained at that level. The nesting is recursive, so +/// a container's children may themselves be containers, and the root +/// is the top-level container. +/// +/// +/// The child subgraph is exposed through and is created lazily: a leaf +/// node allocates no child graph and costs nothing beyond a null reference, so a flat (non-nested) +/// graph behaves exactly as it did before nesting existed. Use to test +/// cheaply whether a node is a container without forcing that allocation. +/// +/// +/// Identifiers are unique per scope: each container (including the root graph) enforces +/// its own node- and edge-identifier uniqueness, so the same identifier may be reused in +/// different scopes but not twice within one scope — exactly as ELK scopes identifiers to a +/// compound node's children. +/// +/// +/// An edge whose endpoints live in different containers (a cross-container edge) is not +/// a new type: it is an ordinary added to an ancestor container — +/// the lowest common ancestor (LCA) of its endpoints, or any container above it — whose +/// and reference the +/// descendant nodes. The model deliberately does not forbid such cross-scope references; a +/// hierarchical layout engine resolves the routing in the owning container's coordinate space. +/// +/// +/// +/// Building a two-level graph — a container node whose hold two nested +/// nodes joined by an intra-container edge, plus a cross-container edge added at the root that +/// references a descendant node: +/// +/// var graph = new LayoutGraph(); +/// +/// // A container node and a peer leaf node at the root scope. +/// var group = graph.AddNode("group", 200, 120); +/// var outside = graph.AddNode("outside", 80, 40); +/// +/// // Nested children live in the container's own scope. +/// var inner1 = group.Children.AddNode("child1", 80, 40); +/// var inner2 = group.Children.AddNode("child2", 80, 40); +/// group.Children.AddEdge("inner-edge", inner1, inner2); // intra-container edge +/// +/// // A cross-container edge lives in the lowest common ancestor (here the root) and +/// // references a descendant node inside the container. +/// graph.AddEdge("cross-edge", outside, inner1); +/// +/// bool groupIsContainer = group.HasChildren; // true +/// bool outsideIsContainer = outside.HasChildren; // false (leaf; no child graph allocated) +/// +/// +public sealed class LayoutGraphNode : PropertyHolder +{ + /// + /// Backing store for the lazily-created child subgraph. Remains for a leaf + /// node so that a non-container node allocates nothing and a flat graph is unchanged. + /// + private LayoutGraph? _children; + + /// + /// Initializes a new instance of the class. + /// + /// Identifier unique within the owning graph. + /// Width of the node's bounding box in logical pixels. + /// Height of the node's bounding box in logical pixels. + /// Thrown when is . + /// Thrown when is empty. + public LayoutGraphNode(string id, double width, double height) + { + ArgumentException.ThrowIfNullOrEmpty(id); + Id = id; + Width = width; + Height = height; + } + + /// Gets the identifier, unique within the owning graph. + public string Id { get; } + + /// Gets or sets the width of the node's bounding box in logical pixels. + public double Width { get; set; } + + /// Gets or sets the height of the node's bounding box in logical pixels. + public double Height { get; set; } + + /// Gets or sets an optional text label rendered inside the node. + public string? Label { get; set; } + + /// + /// Gets the child subgraph nested inside this node, turning the node into a container of nodes and + /// contained edges. + /// + /// + /// + /// Mirrors ELK's compound-node model: accessing this property makes the node a container whose + /// children occupy their own identifier scope. Add nested elements through the returned graph's + /// own factory methods, for example node.Children.AddNode(...) and + /// node.Children.AddEdge(...); identifier-uniqueness, insertion order, and per-element + /// property overrides all behave exactly as they do on the root . + /// + /// + /// The child graph is created lazily on first access, so merely reading this property once + /// promotes the node to a (possibly empty) container. To test whether a node already holds + /// children without triggering that allocation, use . + /// + /// + public LayoutGraph Children => _children ??= new LayoutGraph(); + + /// + /// Gets a value indicating whether this node is a container that currently holds at least one child + /// node. + /// + /// + /// Lets consumers — and a future hierarchical layout engine — distinguish a container from a + /// leaf and skip empty containers cheaply. The check does not allocate the child subgraph: it + /// returns for a leaf node whose has never been + /// accessed, and also for a node whose child graph was materialized but never populated. + /// + public bool HasChildren => _children is { Nodes.Count: > 0 }; +} diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutBadge.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutBadge.cs new file mode 100644 index 0000000..edbfc73 --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutBadge.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Shape of a badge icon. +/// +public enum BadgeShape +{ + /// Solid filled circle. + FilledCircle, + + /// Circle with concentric inner circle (bullseye). + Bullseye, + + /// Diamond (rotated square). + Diamond, + + /// Short horizontal bar. + HorizontalBar, + + /// Short vertical bar. + VerticalBar, +} + +/// +/// A small icon badge at an absolute centre position. +/// +/// Absolute X coordinate of the badge centre in logical pixels. +/// Absolute Y coordinate of the badge centre in logical pixels. +/// Diameter of the badge bounding circle in logical pixels. +/// Visual shape of the badge icon. +/// Optional text label displayed beside the badge; for icon-only badges. +public sealed record LayoutBadge( + double CentreX, + double CentreY, + double Size, + BadgeShape Shape, + string? Label) : LayoutNode; diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutBand.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutBand.cs new file mode 100644 index 0000000..202a4e1 --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutBand.cs @@ -0,0 +1,36 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Orientation of a swim-lane band. +/// +public enum BandOrientation +{ + /// Band runs horizontally. + Horizontal, + + /// Band runs vertically. + Vertical, +} + +/// +/// A swim-lane band with optional label and nested children. +/// +/// Absolute X coordinate of the band left edge in logical pixels. +/// Absolute Y coordinate of the band top edge in logical pixels. +/// Width of the band in logical pixels. +/// Height of the band in logical pixels. +/// Whether the band runs horizontally or vertically. +/// Optional header text displayed at the band edge; for unlabelled bands. +/// Layout nodes nested inside this band. +public sealed record LayoutBand( + double X, + double Y, + double Width, + double Height, + BandOrientation Orientation, + string? Label, + IReadOnlyList Children) : LayoutNode; diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutBox.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutBox.cs new file mode 100644 index 0000000..0533bf4 --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutBox.cs @@ -0,0 +1,61 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Shape of a layout box. +/// +public enum BoxShape +{ + /// Plain rectangle. + Rectangle, + + /// Rectangle with rounded corners. + RoundedRectangle, + + /// Folder shape (rectangle with a tab on the top-left), used for packages. + Folder, + + /// Note shape (rectangle with a folded-down top-right corner), used for documentation and comments. + Note, +} + +/// +/// A single compartment within a box (e.g., attributes section, operations section). +/// +/// Optional compartment header text; for untitled compartments. +/// Text rows displayed inside the compartment. +public sealed record LayoutCompartment( + string? Title, + IReadOnlyList Rows); + +/// +/// A rectangular container node with optional label, depth, compartments, and nested children. +/// +/// Absolute X coordinate of the left edge in logical pixels. +/// Absolute Y coordinate of the top edge in logical pixels. +/// Width of the box in logical pixels. +/// Height of the box in logical pixels. +/// Optional text label displayed at the top of the box. +/// Nesting depth used by the renderer to index into Theme.DepthFillColors. +/// Visual shape of the box outline. +/// Ordered list of compartments displayed below the label. +/// Nested layout nodes contained spatially within this box. +/// +/// Optional SysML keyword (e.g. "part def", "port") rendered on a smaller line +/// above the bold label, following the SysML v2 graphical convention. when no +/// keyword should be shown. +/// +public sealed record LayoutBox( + double X, + double Y, + double Width, + double Height, + string? Label, + int Depth, + BoxShape Shape, + IReadOnlyList Compartments, + IReadOnlyList Children, + string? Keyword = null) : LayoutNode; diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutGrid.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutGrid.cs new file mode 100644 index 0000000..158501c --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutGrid.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// A single cell within a grid row. +/// +/// Width of the cell in logical pixels. +/// Height of the cell in logical pixels. +/// Text content displayed inside the cell. +/// Horizontal alignment of the text within the cell. +/// Number of columns this cell spans; normally 1. +public sealed record LayoutGridCell( + double Width, + double Height, + string Text, + TextAlign Align, + int ColSpan); + +/// +/// A single row within a layout grid. +/// +/// When , the row is rendered with header styling. +/// Ordered list of cells in this row. +public sealed record LayoutGridRow( + bool IsHeader, + IReadOnlyList Cells); + +/// +/// A tabular layout node at an absolute position. +/// +/// Absolute X coordinate of the grid left edge in logical pixels. +/// Absolute Y coordinate of the grid top edge in logical pixels. +/// Ordered list of rows in the grid. +public sealed record LayoutGrid( + double X, + double Y, + IReadOnlyList Rows) : LayoutNode; diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutLabel.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutLabel.cs new file mode 100644 index 0000000..0e9828d --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutLabel.cs @@ -0,0 +1,61 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Horizontal text alignment. +/// +public enum TextAlign +{ + /// Align text to the left. + Left, + + /// Align text to the center. + Center, + + /// Align text to the right. + Right, +} + +/// Weight of a font. +public enum FontWeight +{ + /// Regular (normal) weight. + Regular, + + /// Bold weight. + Bold, +} + +/// Style of a font. +public enum FontStyle +{ + /// Normal (upright) style. + Normal, + + /// Italic style. + Italic, +} + +/// +/// A standalone text label at an absolute position. Width is capped to . +/// +/// Absolute X coordinate of the label origin in logical pixels. +/// Absolute Y coordinate of the label baseline in logical pixels. +/// Maximum width before text wraps or truncates. +/// Text content of the label. +/// Horizontal alignment of the text within the label bounds. +/// Font weight applied to the label text. +/// Font style applied to the label text. +/// Font size in logical pixels. +public sealed record LayoutLabel( + double X, + double Y, + double MaxWidth, + string Text, + TextAlign Align, + FontWeight Weight, + FontStyle Style, + double FontSize) : LayoutNode; diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutLifeline.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutLifeline.cs new file mode 100644 index 0000000..8b6af5b --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutLifeline.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// A sequence-diagram lifeline: a vertical dashed line with a header box. +/// +/// Absolute X coordinate of the lifeline centre in logical pixels. +/// Absolute Y coordinate of the top of the lifeline in logical pixels. +/// Absolute Y coordinate of the bottom of the lifeline in logical pixels. +/// Text label displayed in the header box. +/// Width of the header box in logical pixels. +/// Height of the header box in logical pixels. +public sealed record LayoutLifeline( + double CentreX, + double TopY, + double BottomY, + string Label, + double HeaderWidth, + double HeaderHeight) : LayoutNode; + +/// +/// An activation bar on a lifeline: a narrow rectangle indicating when the lifeline is active. +/// +/// Absolute X coordinate of the activation bar centre in logical pixels. +/// Absolute Y coordinate of the top of the activation bar in logical pixels. +/// Absolute Y coordinate of the bottom of the activation bar in logical pixels. +public sealed record LayoutActivation( + double CentreX, + double TopY, + double BottomY) : LayoutNode; diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutLine.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutLine.cs new file mode 100644 index 0000000..a406b2e --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutLine.cs @@ -0,0 +1,75 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// An immutable 2-D point with absolute coordinates. +/// +/// Absolute X coordinate in logical pixels. +/// Absolute Y coordinate in logical pixels. +public sealed record Point2D(double X, double Y); + +/// +/// Style of an end marker (arrowhead, diamond, circle, or bar) at a line end. +/// +public enum EndMarkerStyle +{ + /// No end marker. + None, + + /// Open chevron: a two-stroke V with no closing edge. + OpenChevron, + + /// Hollow (unfilled) closed triangle. + HollowTriangle, + + /// Hollow closed triangle with a perpendicular crossbar (for redefinition). + HollowTriangleCrossbar, + + /// Filled (solid) triangle arrow. + FilledArrow, + + /// Hollow (open) diamond. + HollowDiamond, + + /// Filled diamond. + FilledDiamond, + + /// Circle. + Circle, + + /// Bar (perpendicular line). + Bar, +} + +/// +/// Stroke style of a line. +/// +public enum LineStyle +{ + /// Solid line. + Solid, + + /// Dashed line. + Dashed, + + /// Dotted line. + Dotted, +} + +/// +/// A pre-routed orthogonal line connecting two nodes. Corner rounding is controlled by Theme.LineCornerRadius. +/// +/// Ordered absolute waypoints; the renderer draws straight segments between consecutive points. +/// End-marker style at the source (start) end of the line. +/// End-marker style at the target (end) end of the line. +/// Stroke style (solid, dashed, dotted) for the line. +/// Optional text label placed at the midpoint of the line; for unlabelled lines. +public sealed record LayoutLine( + IReadOnlyList Waypoints, + EndMarkerStyle SourceEnd, + EndMarkerStyle TargetEnd, + LineStyle LineStyle, + string? MidpointLabel) : LayoutNode; diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutNode.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutNode.cs new file mode 100644 index 0000000..cde6a8d --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutNode.cs @@ -0,0 +1,13 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Abstract base for all layout nodes. Renderers switch on concrete type; unknown subtypes should be skipped for forward compatibility. +/// +// S2094: intentionally empty abstract record — acts as a discriminated-union root; no shared members are needed +#pragma warning disable S2094 +public abstract record LayoutNode; +#pragma warning restore S2094 diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs new file mode 100644 index 0000000..b9ce603 --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs @@ -0,0 +1,36 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Side of a box that a port is attached to. +/// +public enum PortSide +{ + /// Port is on the top edge. + Top, + + /// Port is on the bottom edge. + Bottom, + + /// Port is on the left edge. + Left, + + /// Port is on the right edge. + Right, +} + +/// +/// A port node pinned to the edge of its parent box. Position is absolute. +/// +/// Absolute X coordinate of the port centre in logical pixels. +/// Absolute Y coordinate of the port centre in logical pixels. +/// Edge of the parent box that this port is attached to. +/// Optional text label displayed beside the port symbol. +public sealed record LayoutPort( + double CentreX, + double CentreY, + PortSide Side, + string? Label) : LayoutNode; diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutTree.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutTree.cs new file mode 100644 index 0000000..6c51473 --- /dev/null +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutTree.cs @@ -0,0 +1,23 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Complete layout for one rendered view. All coordinates are absolute (origin = top-left). +/// +/// Canvas width in logical pixels. +/// Canvas height in logical pixels. +/// Flat list of top-level layout nodes to render. +public sealed record LayoutTree( + double Width, + double Height, + IReadOnlyList Nodes) +{ + /// + /// Gets non-fatal layout-quality warnings produced while building this view (e.g. connectors + /// that could not be routed without crossing a box). Empty when the layout is clean. + /// + public IReadOnlyList Warnings { get; init; } = []; +} diff --git a/src/DemaConsulting.Rendering/NamespaceDoc.cs b/src/DemaConsulting.Rendering/NamespaceDoc.cs new file mode 100644 index 0000000..33d90d3 --- /dev/null +++ b/src/DemaConsulting.Rendering/NamespaceDoc.cs @@ -0,0 +1,99 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// The diagram model and entry point for a general-purpose, ELK-inspired diagramming toolkit: the +/// unplaced input , the placed , the core geometry +/// types, and the open property system that configures layout and rendering. +/// +/// +/// +/// Start here. DemaConsulting.Rendering is the root package of a +/// general-purpose, ELK-inspired toolkit that lays out and draws node-and-edge diagrams. It holds +/// the diagram model; the layout algorithms and renderers live in sibling packages that +/// build on it. +/// +/// +/// The model at a glance. You describe a diagram as a — a +/// (possibly nested) graph of sized boxes and directed edges. Laying it out produces a +/// : an immutable tree of placed nodes (, +/// , and the other Layout* record types) carrying absolute +/// coordinates. Geometry is expressed with and . Layout and +/// render behaviour is configured through the open property system (LayoutProperty<T> +/// keys carried on any IPropertyHolder), with the well-known keys gathered on +/// (for example , +/// , and ). +/// +/// The full path to a diagram — where to go next: +/// +/// +/// +/// Build a here in DemaConsulting.Rendering — add sized nodes +/// and directed edges, nesting nodes for compound diagrams. +/// +/// +/// +/// +/// Lay it out with the DemaConsulting.Rendering.Layout package: the one-call +/// LayoutEngine.Layout(graph, options) facade resolves whatever algorithm the graph +/// declares, or select a specific ILayoutAlgorithm (layered, containment, +/// or the recursive hierarchical engine) yourself. This yields a placed +/// . +/// +/// +/// +/// +/// Render the with a renderer from DemaConsulting.Rendering.Svg +/// (SvgRenderer, zero dependencies) or DemaConsulting.Rendering.Skia (raster +/// PNG/JPEG/WEBP). +/// +/// +/// +/// +/// The DemaConsulting.Rendering.Abstractions package defines the ILayoutAlgorithm and +/// IRenderer service-provider contracts, their registries, and the Theme that styles +/// output. The dependency pipeline is model ← Abstractions ← Layout ← +/// Svg/Skia, so this model package depends on nothing and is the natural place to begin reading. +/// +/// +/// Configuration is open and property-based: algorithms and +/// renderers read only the properties they understand, so unknown or not-yet-honoured properties +/// default harmlessly. New diagram families and output formats are added additively by implementing +/// ILayoutAlgorithm or IRenderer and registering them — no existing contract changes. +/// The public surface deliberately mirrors the +/// Eclipse Layout Kernel (ELK) so ELK users are +/// immediately at home. +/// +/// +/// +/// An end-to-end diagram: build a graph, lay it out, and render it to SVG. +/// +/// using System.IO; +/// using DemaConsulting.Rendering; +/// using DemaConsulting.Rendering.Abstractions; +/// using DemaConsulting.Rendering.Layout; +/// using DemaConsulting.Rendering.Svg; +/// +/// // 1. Describe the diagram as a graph of sized boxes and directed edges. +/// 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); +/// graph.AddEdge("a-b", a, b); +/// graph.AddEdge("b-c", b, c); +/// +/// // 2. Lay it out with the one-call facade (default: hierarchical, byte-identical to +/// // layered for this flat graph). +/// LayoutTree tree = LayoutEngine.Layout(graph, new LayoutOptions()); +/// +/// // 3. Render the placed tree to SVG. +/// using var output = File.Create("diagram.svg"); +/// new SvgRenderer().Render(tree, new RenderOptions(Themes.Light), output); +/// +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.Rendering/Options/CoreOptions.cs b/src/DemaConsulting.Rendering/Options/CoreOptions.cs new file mode 100644 index 0000000..951e1fd --- /dev/null +++ b/src/DemaConsulting.Rendering/Options/CoreOptions.cs @@ -0,0 +1,87 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Well-known keys understood by the core layout pipeline. This is +/// the ELK-style option catalog: the set is expected to grow over time, and adding a key is a purely +/// additive change. Keys marked advisory are accepted today but not yet honored by the +/// bundled algorithms; they default harmlessly until an algorithm implements them. +/// +/// +/// Set a well-known option by passing the key together with a value of its type to +/// on any scope — a graph, a +/// node, an edge, or a free-standing . +/// +/// +/// Setting well-known options at two different scopes: +/// +/// // Graph scope: route every edge in this graph orthogonally. +/// var graph = new LayoutGraph(); +/// graph.Set(CoreOptions.EdgeRouting, EdgeRouting.Orthogonal); +/// +/// // Options scope: select the layered algorithm and flow its layers downward. +/// // Prefer the algorithm-id constant over a hardcoded string. +/// var options = LayoutOptions.ForAlgorithm(LayeredLayoutAlgorithm.AlgorithmId); +/// options.Set(CoreOptions.Direction, LayoutFlowDirection.Down); +/// +/// +public static class CoreOptions +{ + /// + /// Identifier of the layout algorithm to apply. Resolved against the registered algorithms by a + /// caller or layout service. Prefer a bundled algorithm-id constant — + /// LayeredLayoutAlgorithm.AlgorithmId ("layered"), + /// ContainmentLayoutAlgorithm.AlgorithmId ("containment"), or + /// HierarchicalLayoutAlgorithm.AlgorithmId ("hierarchical") — over a hardcoded string + /// so callers never have to spell the identifier by hand. + /// + public static readonly LayoutProperty Algorithm = + new("rendering.algorithm", "layered"); + + /// + /// How a hierarchical layout engine treats a container node's nested children, mirroring ELK's + /// elk.hierarchyHandling. Carried on any scope (graph, node, or a free-standing + /// ) so hierarchy handling can be selected per scope just like the + /// algorithm. The value defaults to , + /// which is the only mode honored by the bundled hierarchical engine today: each container is laid + /// out in its own coordinate space and sized to fit its children. The vocabulary grows additively as + /// new hierarchy-handling modes are implemented. + /// + public static readonly LayoutProperty HierarchyHandling = + new("rendering.hierarchyhandling", Rendering.HierarchyHandling.SeparateChildren); + + /// + /// Primary flow direction for layered algorithms. Advisory in the bundled layered + /// algorithm today, which lays out left-to-right; honoring other directions is a future, + /// additive enhancement. + /// + public static readonly LayoutProperty Direction = + new("rendering.direction", LayoutFlowDirection.Right); + + /// + /// Routing style applied to connectors, mirroring ELK's elk.edgeRouting. Carried on any + /// scope (graph, node, edge, or a free-standing ) so routing can be + /// selected per scope just like the algorithm. The bundled routing orchestration reads this key + /// to choose the router; the value defaults to , the only + /// shipped style, and the vocabulary grows additively as new routers are implemented. + /// + public static readonly LayoutProperty EdgeRouting = + new("rendering.edgerouting", Rendering.EdgeRouting.Orthogonal); + + /// + /// Advisory: desired spacing, in logical pixels, between adjacent nodes within a layer. Accepted + /// but not yet honored by the bundled layered algorithm, which uses fixed engine metrics. + /// + public static readonly LayoutProperty NodeSpacing = + new("rendering.spacing.node", 20.0); + + /// + /// Advisory: desired spacing, in logical pixels, between adjacent layers. Accepted but not yet + /// honored by the bundled layered algorithm, which uses fixed engine metrics. + /// + public static readonly LayoutProperty LayerSpacing = + new("rendering.spacing.layer", 40.0); +} diff --git a/src/DemaConsulting.Rendering/Options/EdgeRouting.cs b/src/DemaConsulting.Rendering/Options/EdgeRouting.cs new file mode 100644 index 0000000..fdbe772 --- /dev/null +++ b/src/DemaConsulting.Rendering/Options/EdgeRouting.cs @@ -0,0 +1,35 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// The routing style applied to connectors between placed boxes. This is the ELK-style +/// elk.edgeRouting analogue: it selects how a connector is shaped as it travels from +/// its source box to its target box, independently of which layout algorithm placed the boxes. +/// +/// +/// +/// Routing is a closed vocabulary: unlike the open, registry-based algorithm selection +/// (), the routing styles the library understands are enumerated +/// here. The set is expected to grow additively as new routers are implemented — adding a member is a +/// source-compatible change, so callers that switch exhaustively over the enum should keep a default +/// arm for forward compatibility. +/// +/// +/// Today the enum carries a single value, , because it is the only routing +/// style with a shipped implementation. Additional styles such as straight-line, polyline, or spline +/// routing will be introduced as their routers land; they are intentionally omitted until then so the +/// public surface never advertises a capability the library cannot deliver. +/// +/// +public enum EdgeRouting +{ + /// + /// Axis-aligned, obstacle-avoiding routing: connectors are drawn as a sequence of horizontal and + /// vertical segments that steer around intervening boxes. This is the convention for block, + /// state, and activity diagrams and is realized by the library's internal orthogonal edge router. + /// + Orthogonal = 0, +} diff --git a/src/DemaConsulting.Rendering/Options/HierarchyHandling.cs b/src/DemaConsulting.Rendering/Options/HierarchyHandling.cs new file mode 100644 index 0000000..14cda44 --- /dev/null +++ b/src/DemaConsulting.Rendering/Options/HierarchyHandling.cs @@ -0,0 +1,39 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Selects how a hierarchical layout engine treats a container node's nested children when placing a +/// compound graph. This is the ELK elk.hierarchyHandling analogue: it decides whether each +/// container is laid out in isolation or its children participate in the parent's layout. +/// +/// +/// +/// is the only shipped mode and the default. Under it each +/// container's children are laid out in their own coordinate space by the container's selected +/// algorithm, and the container is then sized to fit the resulting sub-layout (plus padding and an +/// optional title band). Cross-container edges are routed in the coordinate space of the container +/// that owns them — the lowest common ancestor of their endpoints — rather than being folded into a +/// single flattened layout. This keeps every container's placement independent and deterministic, +/// mirroring how ELK lays out compound nodes when hierarchy handling is left separate. +/// +/// +/// A future, additive IncludeChildren mode is planned to mirror ELK's cross-boundary +/// (inclusion) hierarchy handling, in which a container's children take part in the same layout pass +/// as their siblings so edges may cross container boundaries as first-class layout elements. That +/// value is intentionally omitted until an engine implements it, so the public surface never +/// advertises a capability the library cannot yet deliver; adding it later is a source-compatible, +/// purely additive change. +/// +/// +public enum HierarchyHandling +{ + /// + /// Each container's children are laid out in their own coordinate space and the container is sized + /// to enclose them. This is the only shipped mode and the default; cross-container edges are routed + /// by the container that owns them rather than flattened across boundaries. + /// + SeparateChildren = 0, +} diff --git a/src/DemaConsulting.Rendering/Options/IPropertyHolder.cs b/src/DemaConsulting.Rendering/Options/IPropertyHolder.cs new file mode 100644 index 0000000..dee732a --- /dev/null +++ b/src/DemaConsulting.Rendering/Options/IPropertyHolder.cs @@ -0,0 +1,53 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// An open, extensible bag of configuration values keyed by . +/// Layout graphs, their elements, and all implement this contract so +/// that configuration can be attached at any granularity (whole graph, single node, or a shared +/// options object) without changing any method signature as new properties are introduced. +/// +public interface IPropertyHolder +{ + /// + /// Gets the value stored for , or the property's + /// when it has not been set. + /// + /// Type of the property value. + /// The property to read. + /// The stored value, or the property default when unset. + /// Thrown when is . + TValue Get(LayoutProperty property); + + /// + /// Attempts to read a previously-set value for . + /// + /// Type of the property value. + /// The property to read. + /// When this method returns , the stored value. + /// when the property was explicitly set; otherwise . + /// Thrown when is . + bool TryGet(LayoutProperty property, out TValue value); + + /// + /// Sets the value for , replacing any previously-stored value. + /// + /// Type of the property value. + /// The property to write. + /// The value to store. + /// This holder, to support fluent chaining of multiple Set calls. + /// Thrown when is . + IPropertyHolder Set(LayoutProperty property, TValue value); + + /// + /// Determines whether has been explicitly set on this holder. + /// + /// Type of the property value. + /// The property to test. + /// when the property was explicitly set; otherwise . + /// Thrown when is . + bool Contains(LayoutProperty property); +} diff --git a/src/DemaConsulting.Rendering/Options/LayoutFlowDirection.cs b/src/DemaConsulting.Rendering/Options/LayoutFlowDirection.cs new file mode 100644 index 0000000..59f1be9 --- /dev/null +++ b/src/DemaConsulting.Rendering/Options/LayoutFlowDirection.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// The primary flow direction a layered layout algorithm arranges nodes along: successive layers +/// progress in this direction. +/// +public enum LayoutFlowDirection +{ + /// Layers progress left-to-right (the default for most block diagrams). + Right = 0, + + /// Layers progress right-to-left. + Left = 1, + + /// Layers progress top-to-bottom. + Down = 2, + + /// Layers progress bottom-to-top. + Up = 3, +} diff --git a/src/DemaConsulting.Rendering/Options/LayoutOptions.cs b/src/DemaConsulting.Rendering/Options/LayoutOptions.cs new file mode 100644 index 0000000..cc8f0a2 --- /dev/null +++ b/src/DemaConsulting.Rendering/Options/LayoutOptions.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// A free-standing, shareable used to pass configuration to a layout +/// algorithm or renderer independently of any graph element. Well-known keys are declared on +/// ; callers may also set any custom . +/// +public sealed class LayoutOptions : PropertyHolder +{ + /// + /// Creates a pre-set with the given layout algorithm identifier. + /// + /// + /// Identifier of the layout algorithm to run (see ). Prefer a + /// bundled algorithm-id constant — LayeredLayoutAlgorithm.AlgorithmId, + /// ContainmentLayoutAlgorithm.AlgorithmId, or HierarchicalLayoutAlgorithm.AlgorithmId — + /// over a hardcoded string such as "hierarchical". + /// + /// A new options instance with set. + /// Thrown when is . + /// Thrown when is empty. + public static LayoutOptions ForAlgorithm(string algorithmId) + { + ArgumentException.ThrowIfNullOrEmpty(algorithmId); + var options = new LayoutOptions(); + options.Set(CoreOptions.Algorithm, algorithmId); + return options; + } +} diff --git a/src/DemaConsulting.Rendering/Options/LayoutProperty.cs b/src/DemaConsulting.Rendering/Options/LayoutProperty.cs new file mode 100644 index 0000000..b032ac0 --- /dev/null +++ b/src/DemaConsulting.Rendering/Options/LayoutProperty.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// A strongly-typed, uniquely-identified configuration key used to read and write values on an +/// . This is the ELK-inspired open-configuration primitive: new options +/// are introduced by declaring new constants, so the layout and +/// rendering contracts never change signature as configuration coverage grows. +/// +/// Type of the value carried by this property. +public sealed class LayoutProperty +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// Globally-unique identifier for the property, conventionally a dotted name such as + /// rendering.direction. Used as the storage key and to compare properties for equality. + /// + /// + /// Value returned by when the property has not been set. + /// + /// Thrown when is . + /// Thrown when is empty. + public LayoutProperty(string id, T defaultValue) + { + ArgumentException.ThrowIfNullOrEmpty(id); + Id = id; + DefaultValue = defaultValue; + } + + /// Gets the globally-unique identifier for this property. + public string Id { get; } + + /// Gets the value returned when this property has not been explicitly set on a holder. + public T DefaultValue { get; } +} diff --git a/src/DemaConsulting.Rendering/Options/PropertyHolder.cs b/src/DemaConsulting.Rendering/Options/PropertyHolder.cs new file mode 100644 index 0000000..dec81f0 --- /dev/null +++ b/src/DemaConsulting.Rendering/Options/PropertyHolder.cs @@ -0,0 +1,63 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// Dictionary-backed base implementation of . Values are stored keyed +/// by , so unknown or not-yet-honored properties are carried +/// harmlessly and simply ignored by algorithms that do not read them. +/// +public class PropertyHolder : IPropertyHolder +{ + private readonly Dictionary _values = []; + + /// + public TValue Get(LayoutProperty property) + { + ArgumentNullException.ThrowIfNull(property); + return TryGet(property, out var value) ? value : property.DefaultValue; + } + + /// + public bool TryGet(LayoutProperty property, out TValue value) + { + ArgumentNullException.ThrowIfNull(property); + if (_values.TryGetValue(property.Id, out var stored)) + { + // A present key counts as explicitly set, even when the stored value is null + // (for reference or nullable value types). This keeps Contains, TryGet, and Get + // consistent: setting a property to null is honored rather than folded into the default. + if (stored is TValue typed) + { + value = typed; + return true; + } + + if (stored is null) + { + value = default!; + return true; + } + } + + value = property.DefaultValue; + return false; + } + + /// + public IPropertyHolder Set(LayoutProperty property, TValue value) + { + ArgumentNullException.ThrowIfNull(property); + _values[property.Id] = value; + return this; + } + + /// + public bool Contains(LayoutProperty property) + { + ArgumentNullException.ThrowIfNull(property); + return _values.ContainsKey(property.Id); + } +} diff --git a/src/DemaConsulting.Rendering/Rect.cs b/src/DemaConsulting.Rendering/Rect.cs new file mode 100644 index 0000000..1fda5a4 --- /dev/null +++ b/src/DemaConsulting.Rendering/Rect.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering; + +/// +/// An immutable, axis-aligned rectangle defined by the position of its top-left corner and its size, +/// expressed in logical pixels. +/// +/// Absolute X coordinate of the left edge, in logical pixels. +/// Absolute Y coordinate of the top edge, in logical pixels. +/// Width along the X axis, in logical pixels. +/// Height along the Y axis, in logical pixels. +/// +/// +/// is part of the core geometry vocabulary shared across the Rendering libraries, +/// alongside and . It carries no styling or semantic +/// information; it is purely a placed region. +/// +/// +/// Coordinates follow the same conventions as the rest of the model: values are in logical pixels, +/// the origin is at the top-left of the canvas, X increases to the right, and Y increases downward. +/// The rectangle spans the half-open ranges [X, X + Width) horizontally and +/// [Y, Y + Height) vertically; and are expected to be +/// non-negative. +/// +/// +/// +/// +/// // A 120 x 60 box whose top-left corner sits at (10, 20). +/// var box = new Rect(10, 20, 120, 60); +/// +/// // Derive the far edges and centre from the position and size. +/// var right = box.X + box.Width; // 130 +/// var bottom = box.Y + box.Height; // 80 +/// var centre = new Point2D(box.X + (box.Width / 2), box.Y + (box.Height / 2)); // (70, 50) +/// +/// +public readonly record struct Rect(double X, double Y, double Width, double Height); diff --git a/test/DemaConsulting.Rendering.Abstractions.Tests/BoxMetricsTests.cs b/test/DemaConsulting.Rendering.Abstractions.Tests/BoxMetricsTests.cs new file mode 100644 index 0000000..05efa24 --- /dev/null +++ b/test/DemaConsulting.Rendering.Abstractions.Tests/BoxMetricsTests.cs @@ -0,0 +1,75 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Abstractions.Tests; + +/// +/// Tests for , the shared box title-area and folder-tab height +/// formulas that keep space reserved by the layout strategies equal to space drawn by the +/// renderers. Values are asserted against the Light theme's font sizes and label padding. +/// +public sealed class BoxMetricsTests +{ + /// The folder-tab height is the body font size plus two label paddings. + [Fact] + public void BoxMetrics_FolderTabHeight_DerivesFromThemeBodyFontAndPadding() + { + // Arrange: the Light theme (FontSizeBody 12, LabelPadding 6). + var theme = Themes.Light; + + // Act + var height = BoxMetrics.FolderTabHeight(theme); + + // Assert: body font size plus two label paddings (12 + 2*6 = 24). + Assert.Equal(theme.FontSizeBody + (2.0 * theme.LabelPadding), height); + Assert.Equal(24.0, height); + } + + /// A box with neither a name label nor a keyword reserves no title area. + [Fact] + public void BoxMetrics_TitleAreaHeight_NoLabelNoKeyword_IsZero() + { + // Arrange + var theme = Themes.Light; + + // Act + var height = BoxMetrics.TitleAreaHeight(theme, hasLabel: false, hasKeyword: false); + + // Assert + Assert.Equal(0.0, height); + } + + /// A labelled box reserves padding plus one title line. + [Fact] + public void BoxMetrics_TitleAreaHeight_LabelOnly_ReservesTitleLine() + { + // Arrange + var theme = Themes.Light; + + // Act + var height = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: false); + + // Assert: leading padding + (title font + trailing padding). + Assert.Equal(theme.LabelPadding + theme.FontSizeTitle + theme.LabelPadding, height); + } + + /// A box with a keyword and a name reserves padding plus both lines. + [Fact] + public void BoxMetrics_TitleAreaHeight_LabelAndKeyword_ReservesBothLines() + { + // Arrange + var theme = Themes.Light; + + // Act + var height = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true); + + // Assert: leading padding + keyword line + name line, each followed by padding. + Assert.Equal( + theme.LabelPadding + theme.FontSizeBody + theme.LabelPadding + theme.FontSizeTitle + theme.LabelPadding, + height); + } +} diff --git a/test/DemaConsulting.Rendering.Abstractions.Tests/ConnectorLabelPlacerTests.cs b/test/DemaConsulting.Rendering.Abstractions.Tests/ConnectorLabelPlacerTests.cs new file mode 100644 index 0000000..1f04fb4 --- /dev/null +++ b/test/DemaConsulting.Rendering.Abstractions.Tests/ConnectorLabelPlacerTests.cs @@ -0,0 +1,82 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Abstractions.Tests; + +/// +/// Tests for . +/// +public sealed class ConnectorLabelPlacerTests +{ + /// A line without a label is omitted from the result. + [Fact] + public void Place_LineWithoutLabel_IsOmitted() + { + // Arrange + var line = new LayoutLine( + [new Point2D(0, 0), new Point2D(100, 0)], + EndMarkerStyle.None, + EndMarkerStyle.FilledArrow, + LineStyle.Solid, + MidpointLabel: null); + + // Act + var result = ConnectorLabelPlacer.Place([line], fontSize: 12); + + // Assert + Assert.Empty(result); + } + + /// A single labelled line is placed at the midpoint of its longest segment. + [Fact] + public void Place_SingleLine_UsesLongestSegmentMidpoint() + { + // Arrange: a short vertical stub then a long horizontal run; the label should land on the run. + var line = new LayoutLine( + [new Point2D(0, 0), new Point2D(0, 10), new Point2D(200, 10)], + EndMarkerStyle.None, + EndMarkerStyle.FilledArrow, + LineStyle.Solid, + MidpointLabel: "[guard]"); + + // Act + var result = ConnectorLabelPlacer.Place([line], fontSize: 12); + + // Assert + var (x, y) = result[line]; + Assert.Equal(100, x, precision: 3); + Assert.Equal(10, y, precision: 3); + } + + /// Two labels whose preferred positions coincide are separated so they do not overlap. + [Fact] + public void Place_CollidingLabels_AreSeparated() + { + // Arrange: two lines whose longest-segment midpoints are the same point. + var a = new LayoutLine( + [new Point2D(0, 0), new Point2D(200, 0)], + EndMarkerStyle.None, + EndMarkerStyle.FilledArrow, + LineStyle.Solid, + MidpointLabel: "[atFloor]"); + var b = new LayoutLine( + [new Point2D(0, 0), new Point2D(200, 0)], + EndMarkerStyle.None, + EndMarkerStyle.FilledArrow, + LineStyle.Solid, + MidpointLabel: "[timeout]"); + + // Act + var result = ConnectorLabelPlacer.Place([a, b], fontSize: 12); + + // Assert: the first keeps the preferred midpoint; the second is nudged away vertically. + var posA = result[a]; + var posB = result[b]; + Assert.Equal(100, posA.X, precision: 3); + Assert.Equal(0, posA.Y, precision: 3); + Assert.NotEqual(posB.Y, posA.Y, precision: 3); + } +} diff --git a/test/DemaConsulting.Rendering.Abstractions.Tests/DemaConsulting.Rendering.Abstractions.Tests.csproj b/test/DemaConsulting.Rendering.Abstractions.Tests/DemaConsulting.Rendering.Abstractions.Tests.csproj new file mode 100644 index 0000000..eafdec3 --- /dev/null +++ b/test/DemaConsulting.Rendering.Abstractions.Tests/DemaConsulting.Rendering.Abstractions.Tests.csproj @@ -0,0 +1,67 @@ + + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + Exe + false + true + true + + + true + true + true + latest + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/test/DemaConsulting.Rendering.Abstractions.Tests/NotationMetricsTests.cs b/test/DemaConsulting.Rendering.Abstractions.Tests/NotationMetricsTests.cs new file mode 100644 index 0000000..7d36e3a --- /dev/null +++ b/test/DemaConsulting.Rendering.Abstractions.Tests/NotationMetricsTests.cs @@ -0,0 +1,214 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Linq; +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Abstractions.Tests; + +/// +/// Tests for , the single home for intrinsic notation geometry. +/// These tests pin the canonical values and prove every end-marker shape is a documented +/// derivation of named metrics (no geometry literal appears twice in the rendering path). +/// +public sealed class NotationMetricsTests +{ + /// The triangle-family canonical values match the historical SVG marker (10x7, refX 9). + [Fact] + public void TriangleFamily_HasCanonicalValues() + { + // Assert: the triangle-family primitives match the historical SVG marker. + Assert.Equal(10.0, NotationMetrics.EndMarkerLength); + Assert.Equal(7.0, NotationMetrics.EndMarkerWidth); + Assert.Equal(9.0, NotationMetrics.EndMarkerRefX); + Assert.Equal(3.5, NotationMetrics.EndMarkerHalfWidth); + + // The apex overshoots the endpoint by markerLength - refX = 1. + Assert.Equal(1.0, NotationMetrics.EndMarkerTipOvershoot); + } + + /// The diamond canonical values match the historical SVG marker (14x8, refX 13). + [Fact] + public void Diamond_HasCanonicalValues() + { + // Assert: the diamond primitives match the historical SVG marker. + Assert.Equal(14.0, NotationMetrics.DiamondLength); + Assert.Equal(8.0, NotationMetrics.DiamondWidth); + Assert.Equal(13.0, NotationMetrics.DiamondRefX); + Assert.Equal(4.0, NotationMetrics.DiamondHalfWidth); + Assert.Equal(7.0, NotationMetrics.DiamondMidX); + Assert.Equal(1.0, NotationMetrics.DiamondNearX); + } + + /// Circle and bar canonical values match the historical SVG markers (r4 / 4x12). + [Fact] + public void CircleAndBar_HaveCanonicalValues() + { + // Assert: the circle and bar primitives match the historical SVG markers. + Assert.Equal(4.0, NotationMetrics.CircleRadius); + Assert.Equal(10.0, NotationMetrics.CircleMarkerBox); + Assert.Equal(5.0, NotationMetrics.CircleCenter); + Assert.Equal(9.0, NotationMetrics.CircleRefX); + + Assert.Equal(4.0, NotationMetrics.BarAlong); + Assert.Equal(12.0, NotationMetrics.BarAcross); + Assert.Equal(2.0, NotationMetrics.BarHalfAlong); + Assert.Equal(6.0, NotationMetrics.BarHalf); + } + + /// The crossbar position is the documented fraction of the marker length (0.7 x 10 = 7). + [Fact] + public void Crossbar_IsDerivedFraction() + { + // Assert: the crossbar sits at the documented fraction of the marker length. + Assert.Equal(0.7, NotationMetrics.CrossbarFraction); + Assert.Equal(7.0, Math.Round(NotationMetrics.CrossbarX, 6)); + } + + /// + /// The triangle vertices map to the historical SVG marker-box points + /// 0 0, 10 3.5, 0 7 using boxX = refX - Along, boxY = refY + Across. + /// + [Fact] + public void TriangleVertices_ReproduceSvgBoxPoints() + { + // Arrange: the shared triangle vertices in tip-relative units. + var vertices = NotationMetrics.TriangleVertices(); + + // Act: map them back to SVG marker-box coordinates. + var box = MapToBox(vertices, NotationMetrics.EndMarkerRefX, NotationMetrics.EndMarkerHalfWidth); + + // Assert: they reproduce the historical marker-box points. + Assert.Equal("0 0, 10 3.5, 0 7", box); + } + + /// + /// The diamond vertices map to the historical SVG marker-box points 1 4, 7 0, 13 4, 7 8. + /// + [Fact] + public void DiamondVertices_ReproduceSvgBoxPoints() + { + // Arrange: the shared diamond vertices in tip-relative units. + var vertices = NotationMetrics.DiamondVertices(); + + // Act: map them back to SVG marker-box coordinates. + var box = MapToBox(vertices, NotationMetrics.DiamondRefX, NotationMetrics.DiamondHalfWidth); + + // Assert: they reproduce the historical marker-box points. + Assert.Equal("1 4, 7 0, 13 4, 7 8", box); + } + + /// The diamond far point lands exactly on the line endpoint (Along == 0). + [Fact] + public void DiamondVertices_FarPoint_LandsOnEndpoint() + { + // Arrange: the shared diamond vertices. + var vertices = NotationMetrics.DiamondVertices(); + + // Assert: one vertex sits exactly on the line endpoint (Along == Across == 0). + Assert.Contains(vertices, v => Math.Abs(v.Along) < 1e-9 && Math.Abs(v.Across) < 1e-9); + } + + /// The triangle apex overshoots the endpoint (negative Along) by the documented amount. + [Fact] + public void TriangleVertices_Apex_OvershootsEndpoint() + { + // Arrange: the shared triangle vertices; the apex is the middle vertex. + var vertices = NotationMetrics.TriangleVertices(); + var apex = vertices[1]; + + // Assert: the apex overshoots the endpoint by the documented amount. + Assert.Equal(-NotationMetrics.EndMarkerTipOvershoot, apex.Along); + Assert.Equal(0.0, apex.Across); + } + + /// Each end-marker style reports the documented along-line length. + [Theory] + [InlineData(EndMarkerStyle.None, 0.0)] + [InlineData(EndMarkerStyle.OpenChevron, 10.0)] + [InlineData(EndMarkerStyle.HollowTriangle, 10.0)] + [InlineData(EndMarkerStyle.HollowTriangleCrossbar, 10.0)] + [InlineData(EndMarkerStyle.FilledArrow, 10.0)] + [InlineData(EndMarkerStyle.HollowDiamond, 14.0)] + [InlineData(EndMarkerStyle.FilledDiamond, 14.0)] + [InlineData(EndMarkerStyle.Circle, 10.0)] + [InlineData(EndMarkerStyle.Bar, 4.0)] + public void AlongLineLength_MatchesMarkerBox(EndMarkerStyle style, double expected) + { + // Assert: each marker style reports its documented along-line length. + Assert.Equal(expected, NotationMetrics.AlongLineLength(style)); + } + + /// The rounded-rectangle radius is the theme corner radius scaled by the documented factor. + [Fact] + public void RoundedRectRadius_IsThemeRadiusTimesFactor() + { + // Arrange: a theme providing the base line-corner radius. + var theme = Themes.Light; + + // Assert: the rounded-rectangle radius is the theme radius scaled by the documented factor. + Assert.Equal(2.0, NotationMetrics.RoundedRectCornerFactor); + Assert.Equal(theme.LineCornerRadius * 2.0, NotationMetrics.RoundedRectRadius(theme)); + } + + /// The label-background extent is symmetric about the documented inset. + [Fact] + public void LabelBackground_ExtentMatchesInset() + { + // Assert: the label-background extent is symmetric about the documented inset. + Assert.Equal(0.05, NotationMetrics.LabelBgInset); + Assert.Equal(1.1, NotationMetrics.LabelBgExtent); + } + + /// The port square is a full side length of twice the documented half-size (4 → 8). + [Fact] + public void Port_SizeIsTwiceHalfSize() + { + // Assert: the full port side length is twice the half-size. + Assert.Equal(4.0, NotationMetrics.PortHalfSize); + Assert.Equal(8.0, NotationMetrics.PortSize); + Assert.Equal(NotationMetrics.PortHalfSize * 2.0, NotationMetrics.PortSize); + } + + /// The folder-tab constants pin the documented max-width fraction, min width, and label factor. + [Fact] + public void FolderTab_HasDocumentedConstants() + { + // Assert: the folder-tab sizing constants match their documented values. + Assert.Equal(0.45, NotationMetrics.FolderTabMaxWidthFraction); + Assert.Equal(60.0, NotationMetrics.FolderTabMinWidth); + Assert.Equal(0.55, NotationMetrics.FolderLabelCharWidthFactor); + } + + /// The note dog-ear fold constants pin the documented fraction and maximum size. + [Fact] + public void NoteFold_HasDocumentedConstants() + { + // Assert: the note-fold constants match their documented values. + Assert.Equal(0.25, NotationMetrics.NoteFoldFraction); + Assert.Equal(16.0, NotationMetrics.NoteFoldMaxSize); + } + + /// The badge fraction constants pin the bullseye inner radius (1/3) and bar half-length (0.8) fractions. + [Fact] + public void BadgeFractions_HaveDocumentedValues() + { + // Assert: the badge fractions match their documented values. + Assert.Equal(1.0 / 3.0, NotationMetrics.BadgeBullseyeInnerFraction); + Assert.Equal(0.8, NotationMetrics.BadgeBarLengthFraction); + } + + /// Maps tip-relative vertices to an SVG-style marker-box points string. + private static string MapToBox(IReadOnlyList vertices, double refAlong, double refAcross) + { + return string.Join( + ", ", + vertices.Select(v => $"{Num(refAlong - v.Along)} {Num(refAcross + v.Across)}")); + } + + /// Formats a coordinate exactly as the renderers do (rounded, invariant culture). + private static string Num(double value) => + Math.Round(value, 6).ToString(System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/test/DemaConsulting.Rendering.Abstractions.Tests/RegistryTests.cs b/test/DemaConsulting.Rendering.Abstractions.Tests/RegistryTests.cs new file mode 100644 index 0000000..ac19481 --- /dev/null +++ b/test/DemaConsulting.Rendering.Abstractions.Tests/RegistryTests.cs @@ -0,0 +1,102 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Abstractions.Tests; + +/// +/// Tests for the pluggable and +/// service-provider lookups. +/// +public class RegistryTests +{ + /// + /// Proves that a registered algorithm can be found by identifier. + /// + [Fact] + public void LayoutAlgorithmRegistry_RegisterThenResolve_ReturnsAlgorithm() + { + // Arrange + var registry = new LayoutAlgorithmRegistry(); + + // Act + registry.Register(new FakeAlgorithm()); + + // Assert + Assert.True(registry.Contains("fake")); + Assert.Equal("fake", registry.Resolve("fake").Id); + } + + /// + /// Proves that resolving an unregistered algorithm throws. + /// + [Fact] + public void LayoutAlgorithmRegistry_ResolveMissing_Throws() + { + // Arrange + var registry = new LayoutAlgorithmRegistry(); + + // Act / Assert + Assert.Throws(() => registry.Resolve("missing")); + } + + /// + /// Proves that a registered renderer can be found by media type. + /// + [Fact] + public void RendererRegistry_RegisterThenResolve_ReturnsRenderer() + { + // Arrange + var registry = new RendererRegistry(); + + // Act + registry.Register(new FakeRenderer()); + + // Assert + Assert.True(registry.Contains("text/plain")); + Assert.Equal("text/plain", registry.Resolve("text/plain").MediaType); + } + + /// + /// Proves that a registered renderer can be found by any file extension it advertises, + /// with or without a leading dot and regardless of case. + /// + [Fact] + public void RendererRegistry_ResolveByExtension_MatchesAdvertisedExtensions() + { + // Arrange + var registry = new RendererRegistry(); + + // Act + registry.Register(new FakeRenderer()); + + // Assert + Assert.True(registry.ContainsExtension(".txt")); + Assert.Equal("text/plain", registry.ResolveByExtension(".txt").MediaType); + // Leading dot optional and matching is case-insensitive. + Assert.Equal("text/plain", registry.ResolveByExtension("TEXT").MediaType); + } + + private sealed class FakeAlgorithm : ILayoutAlgorithm + { + public string Id => "fake"; + + public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) => new(0, 0, []); + } + + private sealed class FakeRenderer : IRenderer + { + public string MediaType => "text/plain"; + + public string DefaultExtension => ".txt"; + + public IReadOnlyList FileExtensions => [".txt", ".text"]; + + public void Render(LayoutTree layout, RenderOptions options, Stream output) + { + // No-op fake used only to exercise registry lookup. + } + } +} diff --git a/test/DemaConsulting.Rendering.Abstractions.Tests/ThemeTests.cs b/test/DemaConsulting.Rendering.Abstractions.Tests/ThemeTests.cs new file mode 100644 index 0000000..ac9fddd --- /dev/null +++ b/test/DemaConsulting.Rendering.Abstractions.Tests/ThemeTests.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Abstractions.Tests; + +/// +/// Tests for connector geometry fields and the approach-zone helper. +/// +public sealed class ThemeTests +{ + /// The approach zone sums the stub, bend radius, and supplied clearance. + [Fact] + public void ConnectorApproachZone_SumsStubBendAndClearance() + { + // Arrange: Light theme has ConnectorStub 8 and BendRadius 4 + var theme = Themes.Light; + + // Act + var zone = theme.ConnectorApproachZone(connectorClearance: 10.0); + + // Assert: 8 + 4 + 10 + Assert.Equal(22.0, zone, 6); + } + + /// Light and Dark themes carry the same connector geometry; Print is tighter. + [Fact] + public void Themes_HaveExpectedConnectorGeometry() + { + // Act / Assert: each built-in theme exposes its documented connector geometry + Assert.Equal(8.0, Themes.Light.ConnectorStub, 6); + Assert.Equal(4.0, Themes.Light.BendRadius, 6); + Assert.Equal(8.0, Themes.Dark.ConnectorStub, 6); + Assert.Equal(4.0, Themes.Dark.BendRadius, 6); + Assert.Equal(6.0, Themes.Print.ConnectorStub, 6); + Assert.Equal(0.0, Themes.Print.BendRadius, 6); + } +} diff --git a/test/DemaConsulting.Rendering.Gallery/DemaConsulting.Rendering.Gallery.csproj b/test/DemaConsulting.Rendering.Gallery/DemaConsulting.Rendering.Gallery.csproj new file mode 100644 index 0000000..0aad994 --- /dev/null +++ b/test/DemaConsulting.Rendering.Gallery/DemaConsulting.Rendering.Gallery.csproj @@ -0,0 +1,71 @@ + + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + Exe + false + true + true + + + true + true + true + latest + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs new file mode 100644 index 0000000..831ef2b --- /dev/null +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -0,0 +1,111 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Gallery; + +/// +/// One generated showcase image: its stable output filename plus the alt text and caption used in +/// the browsable gallery.md index. +/// +/// Stable output filename (for example 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); + +/// +/// A titled group of related showcase images with a short introduction, rendered as one section of +/// the gallery.md index. +/// +/// 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); + +/// +/// 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 +/// generator lays the same entries out as Markdown, so the two never drift apart. +/// +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 ContainmentPackedSvg = "containment-packed.svg"; + public const string HierarchicalNestedSvg = "hierarchical-nested.svg"; + public const string OrthogonalObstacleSvg = "orthogonal-obstacle.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"; + + /// Gets the browsable sections of the gallery, in display order. + public static IReadOnlyList Sections { 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( + "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( + "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( + "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 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."), + ]), + ]; +} diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs new file mode 100644 index 0000000..9756006 --- /dev/null +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -0,0 +1,163 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Gallery; + +/// +/// Builds the stable, deterministic input graphs the gallery showcase renders. Every diagram is +/// described purely through the public model so the committed images do +/// not churn between runs. +/// +internal static class GalleryDiagrams +{ + /// + /// A directed processing pipeline with a side branch, suited to the layered algorithm which + /// places it in left-to-right layers and routes the edges between them. + /// + /// A flat graph of labelled boxes joined by arrowed edges. + public static LayoutGraph LayeredPipeline() + { + var graph = new LayoutGraph(); + var ingest = AddLabelled(graph, "ingest", "Ingest"); + var validate = AddLabelled(graph, "validate", "Validate"); + var transform = AddLabelled(graph, "transform", "Transform"); + var render = AddLabelled(graph, "render", "Render"); + var publish = AddLabelled(graph, "publish", "Publish"); + var report = AddLabelled(graph, "report", "Report"); + + Connect(graph, "ingest-validate", ingest, validate); + Connect(graph, "validate-transform", validate, transform); + Connect(graph, "transform-render", transform, render); + Connect(graph, "render-publish", render, publish); + Connect(graph, "validate-report", validate, report); + + return graph; + } + + /// + /// A set of differently-sized sibling boxes with no edges, suited to the containment algorithm + /// which shelf-packs them left-to-right and wraps them into a compact block. The varied widths + /// make the row packing visible, distinguishing it from the layered algorithm's connectivity-driven + /// layers. + /// + /// A flat graph of labelled boxes of varied widths for packing. + public static LayoutGraph ContainmentPacked() + { + var graph = new LayoutGraph(); + + // Labelled peers of varied widths; with no edges, the containment algorithm packs them by + // reading order into rows that wrap into a tidy rectangle. + (string Label, double Width)[] boxes = + [ + ("Core", 90), + ("Model", 150), + ("Layout", 110), + ("Svg", 70), + ("Skia", 80), + ("Abstractions", 200), + ("Themes", 120), + ("Options", 130), + ("Engine", 110), + ("Registry", 140), + ("Renderer", 150), + ("Graph", 90), + ]; + + for (var i = 0; i < boxes.Length; i++) + { + var node = graph.AddNode($"box{i}", boxes[i].Width, 50); + node.Label = boxes[i].Label; + } + + return graph; + } + + /// + /// A container node holding a nested child graph, plus an external leaf joined to a child by a + /// cross-container edge — the canonical hierarchical (nested) diagram. + /// + /// A two-level compound graph. + public static LayoutGraph HierarchicalNested() + { + var graph = new LayoutGraph(); + + var service = graph.AddNode("service", 10, 10); + service.Label = "Service"; + var api = AddLabelled(service.Children, "api", "API"); + var worker = AddLabelled(service.Children, "worker", "Worker"); + var store = AddLabelled(service.Children, "store", "Store"); + Connect(service.Children, "api-worker", api, worker); + Connect(service.Children, "worker-store", worker, store); + + var client = AddLabelled(graph, "client", "Client"); + + // A cross-container edge added at the lowest common ancestor (the root) referencing a + // descendant node inside the container. + Connect(graph, "client-api", client, api); + + return graph; + } + + /// + /// Three sibling containers packed in a row with a cross-container edge from a child of the first + /// to a child of the third, forcing the orthogonal router to step around the middle container. + /// + /// A compound graph whose cross-container edge must avoid an intervening obstacle. + public static LayoutGraph OrthogonalObstacle() + { + var graph = new LayoutGraph(); + + var left = graph.AddNode("left", 10, 10); + left.Label = "Left"; + var source = AddLabelled(left.Children, "source", "Source"); + + var middle = graph.AddNode("middle", 10, 10); + middle.Label = "Obstacle"; + AddLabelled(middle.Children, "blocker", "Blocker"); + + var right = graph.AddNode("right", 10, 10); + right.Label = "Right"; + var sink = AddLabelled(right.Children, "sink", "Sink"); + + Connect(graph, "source-sink", source, sink); + + return graph; + } + + /// + /// A compact nested diagram used to compare the built-in themes: a container with two children + /// and an edge exercises depth-based fills, stroke colour, and corner rounding. + /// + /// A two-level compound graph for theme comparison. + public static LayoutGraph ThemeShowcase() + { + var graph = new LayoutGraph(); + + var module = graph.AddNode("module", 10, 10); + module.Label = "Module"; + var input = AddLabelled(module.Children, "input", "Input"); + var output = AddLabelled(module.Children, "output", "Output"); + Connect(module.Children, "input-output", input, output); + + var sink = AddLabelled(graph, "consumer", "Consumer"); + Connect(graph, "output-consumer", output, sink); + + return graph; + } + + /// Adds a labelled leaf node of the standard showcase size to the given graph. + private static LayoutGraphNode AddLabelled(LayoutGraph graph, string id, string label) + { + var node = graph.AddNode(id, 120, 50); + node.Label = label; + return node; + } + + /// Adds a directed edge drawn with a filled arrowhead at its target end. + private static void Connect(LayoutGraph graph, string id, LayoutGraphNode source, LayoutGraphNode target) + { + var edge = graph.AddEdge(id, source, target); + edge.TargetEnd = EndMarkerStyle.FilledArrow; + } +} diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryIndex.cs b/test/DemaConsulting.Rendering.Gallery/GalleryIndex.cs new file mode 100644 index 0000000..52d66a1 --- /dev/null +++ b/test/DemaConsulting.Rendering.Gallery/GalleryIndex.cs @@ -0,0 +1,110 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Text; + +namespace DemaConsulting.Rendering.Gallery; + +/// +/// Generates the browsable gallery.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. +/// +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 gallery.md document, ending with a single newline. + public static string Build() + { + 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."); + AppendParagraph( + builder, + "Regenerate this page and its images by running `./gallery.ps1` from the repository root."); + + foreach (var section in GalleryCatalog.Sections) + { + AppendParagraph(builder, $"## {section.Title}"); + AppendParagraph(builder, section.Intro); + + foreach (var image in section.Images) + { + AppendParagraph(builder, $"![{image.Alt}]({image.FileName})"); + AppendParagraph(builder, image.Caption); + } + } + + // Collapse the trailing paragraph separator to a single terminating newline (MD047). + return builder.ToString().TrimEnd('\n') + "\n"; + } + + /// + /// Writes the generated index to gallery.md in the given directory. + /// + /// The directory that receives gallery.md. + /// The absolute path of the written index file. + public static string Write(string directory) + { + var path = Path.Combine(directory, "gallery.md"); + File.WriteAllText(path, Build(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return path; + } + + /// + /// Appends a paragraph — its text word-wrapped to — followed by a + /// blank separator line, so headings and paragraphs are always blank-line separated. + /// + private static void AppendParagraph(StringBuilder builder, string text) + { + foreach (var line in WrapToWidth(text, MaxLineLength)) + { + builder.Append(line).Append('\n'); + } + + builder.Append('\n'); + } + + /// + /// Greedily wraps on word boundaries so no line exceeds + /// columns. A single word longer than the width is emitted on its own + /// line rather than broken. + /// + private static IEnumerable WrapToWidth(string text, int width) + { + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var line = new StringBuilder(); + + foreach (var word in words) + { + if (line.Length == 0) + { + line.Append(word); + } + else if (line.Length + 1 + word.Length <= width) + { + line.Append(' ').Append(word); + } + else + { + yield return line.ToString(); + line.Clear(); + line.Append(word); + } + } + + if (line.Length > 0) + { + yield return line.ToString(); + } + } +} diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryIndexTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryIndexTests.cs new file mode 100644 index 0000000..a0f23c5 --- /dev/null +++ b/test/DemaConsulting.Rendering.Gallery/GalleryIndexTests.cs @@ -0,0 +1,51 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Gallery; + +/// +/// Exercises the gallery.md index generator. 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. +/// +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. + /// + [Fact] + public void Gallery_Index_ReferencesEveryImageAndSection() + { + // Arrange / Act: generate the index from the catalog. + var markdown = GalleryIndex.Build(); + + // Assert: the document is well-formed and complete. + Assert.StartsWith("# Rendering Gallery", markdown, StringComparison.Ordinal); + Assert.EndsWith("\n", markdown, StringComparison.Ordinal); + + foreach (var section in GalleryCatalog.Sections) + { + Assert.Contains($"## {section.Title}", markdown, StringComparison.Ordinal); + foreach (var image in section.Images) + { + Assert.Contains($"![{image.Alt}]({image.FileName})", markdown, StringComparison.Ordinal); + } + } + + // No line exceeds the 120-column markdownlint limit. + foreach (var line in markdown.Split('\n')) + { + Assert.True(line.Length <= 120, $"Index line exceeds 120 columns: {line}"); + } + + // In gallery mode, persist the index alongside the generated images and confirm it exists. + if (GalleryOutput.IsGalleryMode) + { + var path = GalleryIndex.Write(GalleryOutput.ResolveDirectory()); + Assert.True(File.Exists(path), $"Expected gallery index to be written: {path}"); + } + } +} diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryOutput.cs b/test/DemaConsulting.Rendering.Gallery/GalleryOutput.cs new file mode 100644 index 0000000..f3757e4 --- /dev/null +++ b/test/DemaConsulting.Rendering.Gallery/GalleryOutput.cs @@ -0,0 +1,49 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Gallery; + +/// +/// Resolves where the gallery showcase writes its generated images and index. +/// +/// +/// +/// The gallery facts double as a rendering smoke test that runs in the normal test suite. To keep +/// ordinary build.ps1/CI runs from dirtying the repository, the output directory is read from +/// the RENDERING_GALLERY_DIR environment variable and falls back to a throwaway folder under +/// the test's own output directory when the variable is unset. +/// +/// +/// Setting RENDERING_GALLERY_DIR to the committed docs/gallery folder (as the +/// -Gallery invocation does) switches the same facts into "regenerate the committed showcase" +/// mode, in which the browsable gallery.md index is (re)written alongside the images. +/// +/// +internal static class GalleryOutput +{ + /// Name of the environment variable that selects the gallery output directory. + public const string DirectoryEnvironmentVariable = "RENDERING_GALLERY_DIR"; + + /// + /// Gets a value indicating whether the gallery is running in "regenerate the committed showcase" + /// mode, selected by setting . + /// + public static bool IsGalleryMode => + !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(DirectoryEnvironmentVariable)); + + /// + /// Gets the directory that receives the generated images and index, creating it if necessary. + /// + /// The absolute path of the (now-existing) output directory. + public static string ResolveDirectory() + { + var configured = Environment.GetEnvironmentVariable(DirectoryEnvironmentVariable); + var directory = string.IsNullOrWhiteSpace(configured) + ? Path.Combine(AppContext.BaseDirectory, "gallery-output") + : configured; + + Directory.CreateDirectory(directory); + return directory; + } +} diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs new file mode 100644 index 0000000..b07c39b --- /dev/null +++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs @@ -0,0 +1,143 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Gallery; + +/// +/// The gallery showcase: each fact renders one curated diagram to a stable filename and asserts the +/// produced image is valid. The set spans the breadth of the library — every bundled algorithm, +/// orthogonal edge routing around an obstacle, all three themes, and both the SVG and raster output +/// paths — so a normal test run doubles as a real end-to-end rendering smoke test. Output goes to a +/// throwaway directory unless RENDERING_GALLERY_DIR selects the committed showcase folder. +/// +public sealed class GalleryShowcaseTests +{ + /// + /// Renders the layered pipeline diagram to SVG, proving the layered algorithm and SVG renderer + /// produce a well-formed document. + /// + [Fact] + public void Gallery_LayeredPipeline_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.LayeredPipelineSvg, + GalleryDiagrams.LayeredPipeline(), + LayoutOptions.ForAlgorithm("layered"), + Themes.Dark); + } + + /// + /// Renders the containment-packed diagram to SVG, proving the containment algorithm packs + /// sibling boxes into a valid document. + /// + [Fact] + public void Gallery_ContainmentPacked_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.ContainmentPackedSvg, + GalleryDiagrams.ContainmentPacked(), + LayoutOptions.ForAlgorithm("containment"), + Themes.Dark); + } + + /// + /// Renders the hierarchical nested diagram to SVG, proving the hierarchical engine composes a + /// container node's child graph and a cross-container edge into a valid document. + /// + [Fact] + public void Gallery_HierarchicalNested_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.HierarchicalNestedSvg, + GalleryDiagrams.HierarchicalNested(), + new LayoutOptions(), + Themes.Dark); + } + + /// + /// Renders the obstacle-routing diagram to SVG, proving a cross-container edge is routed + /// orthogonally around an intervening container. + /// + [Fact] + public void Gallery_OrthogonalObstacle_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.OrthogonalObstacleSvg, + GalleryDiagrams.OrthogonalObstacle(), + new LayoutOptions(), + Themes.Dark); + } + + /// + /// Renders the representative diagram with the light theme to PNG, giving it a solid light + /// background. + /// + [Fact] + public void Gallery_ThemeLight_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.ThemeLightPng, + GalleryDiagrams.ThemeShowcase(), + new LayoutOptions(), + Themes.Light); + } + + /// + /// Renders the representative diagram with the dark theme to PNG, giving it a solid dark + /// background. + /// + [Fact] + public void Gallery_ThemeDark_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.ThemeDarkPng, + GalleryDiagrams.ThemeShowcase(), + new LayoutOptions(), + Themes.Dark); + } + + /// + /// Renders the representative diagram with the print theme to PNG, giving it a solid light + /// background suited to black-and-white output. + /// + [Fact] + public void Gallery_ThemePrint_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.ThemePrintPng, + GalleryDiagrams.ThemeShowcase(), + new LayoutOptions(), + Themes.Print); + } + + /// + /// Renders the layered pipeline diagram to PNG, proving the SkiaSharp raster path produces a + /// valid, decodable image. + /// + [Fact] + public void Gallery_LayeredPipeline_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.LayeredPipelinePng, + GalleryDiagrams.LayeredPipeline(), + LayoutOptions.ForAlgorithm("layered"), + Themes.Dark); + } + + /// + /// Renders the hierarchical nested diagram to PNG, proving the raster path handles composed + /// nested layouts. + /// + [Fact] + public void Gallery_HierarchicalNested_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.HierarchicalNestedPng, + GalleryDiagrams.HierarchicalNested(), + new LayoutOptions(), + Themes.Dark); + } +} diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs b/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs new file mode 100644 index 0000000..296c529 --- /dev/null +++ b/test/DemaConsulting.Rendering.Gallery/GalleryWriter.cs @@ -0,0 +1,97 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Text; + +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout; +using DemaConsulting.Rendering.Skia; +using DemaConsulting.Rendering.Svg; + +using SkiaSharp; + +namespace DemaConsulting.Rendering.Gallery; + +/// +/// Lays out a , renders it to the gallery output directory, and asserts the +/// produced file is a valid, non-empty image. This is the shared engine behind every showcase fact, +/// so each fact reduces to "describe a graph, name a file, pick a theme". +/// +internal static class GalleryWriter +{ + private static readonly SvgRenderer SvgRenderer = new(); + private static readonly PngRenderer PngRenderer = new(); + + /// PNG file signature (the eight leading bytes of every PNG stream). + private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + /// + /// Lays out with , renders it to + /// as SVG, and asserts the result is a well-formed SVG document. + /// + /// Stable output filename (for example layered-pipeline.svg). + /// The graph to lay out. + /// The layout options selecting the algorithm. + /// The theme to render with. + public static void Svg(string fileName, LayoutGraph graph, LayoutOptions options, Theme theme) + { + var tree = LayoutEngine.Layout(graph, options); + var path = Path.Combine(GalleryOutput.ResolveDirectory(), fileName); + + using (var stream = File.Create(path)) + { + SvgRenderer.Render(tree, new RenderOptions(theme), stream); + } + + AssertValidSvg(path); + } + + /// + /// Lays out with , renders it to + /// as PNG, and asserts the result decodes as a valid raster image. + /// + /// Stable output filename (for example layered-pipeline.png). + /// The graph to lay out. + /// The layout options selecting the algorithm. + /// The theme to render with. + public static void Png(string fileName, LayoutGraph graph, LayoutOptions options, Theme theme) + { + var tree = LayoutEngine.Layout(graph, options); + var path = Path.Combine(GalleryOutput.ResolveDirectory(), fileName); + + using (var stream = File.Create(path)) + { + PngRenderer.Render(tree, new RenderOptions(theme), stream); + } + + AssertValidPng(path); + } + + /// Asserts that the file exists and contains a well-formed, non-empty SVG document. + /// Absolute path of the generated SVG file. + public static void AssertValidSvg(string path) + { + Assert.True(File.Exists(path), $"Expected SVG file to exist: {path}"); + var content = File.ReadAllText(path, Encoding.UTF8); + Assert.False(string.IsNullOrWhiteSpace(content), $"SVG file is empty: {path}"); + Assert.Contains("", content, StringComparison.Ordinal); + } + + /// Asserts that the file exists, starts with the PNG signature, and decodes to a bitmap. + /// Absolute path of the generated PNG file. + public static void AssertValidPng(string path) + { + Assert.True(File.Exists(path), $"Expected PNG file to exist: {path}"); + var bytes = File.ReadAllBytes(path); + Assert.True(bytes.Length > PngSignature.Length, $"PNG file is too small: {path}"); + Assert.True( + bytes.Take(PngSignature.Length).SequenceEqual(PngSignature), + $"File does not start with the PNG signature: {path}"); + + using var bitmap = SKBitmap.Decode(path); + Assert.NotNull(bitmap); + Assert.True(bitmap.Width > 0 && bitmap.Height > 0, $"Decoded PNG has no pixels: {path}"); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs new file mode 100644 index 0000000..cc1e1f4 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/ConnectorRouterTests.cs @@ -0,0 +1,286 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout; + +namespace DemaConsulting.Rendering.Layout.Tests; + +/// +/// Tests for connector routing orchestration among placed boxes. +/// +public sealed class ConnectorRouterTests +{ + /// + /// Creates a plain rectangular at the given placement with no + /// compartments or children. + /// + private static LayoutBox Box(double x, double y, double width, double height, string? label = null) => + new(x, y, width, height, label, 0, BoxShape.Rectangle, [], []); + + /// + /// When the target box lies to the right, the source anchor sits on the source box's right + /// face and the target anchor on the target box's left face — each box presents the face that + /// points at the other. + /// + [Fact] + public void Route_TargetToTheRight_AnchorsFaceEachOther() + { + // Arrange: two boxes side by side, target to the right of source, no obstacles between + var from = Box(0, 0, 60, 60); + var to = Box(200, 0, 60, 60); + var boxes = new[] { from, to }; + + // Act: route the single connection + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: the route starts at the source right-edge midpoint and ends at the target left-edge midpoint + var start = line.Waypoints[0]; + var end = line.Waypoints[^1]; + Assert.Equal(from.X + from.Width, start.X, 6); // right face of source + Assert.Equal(from.Y + (from.Height / 2.0), start.Y, 6); + Assert.Equal(to.X, end.X, 6); // left face of target + Assert.Equal(to.Y + (to.Height / 2.0), end.Y, 6); + } + + /// + /// When the target box lies below, the source anchor sits on the source box's bottom face and + /// the target anchor on the target box's top face. + /// + [Fact] + public void Route_TargetBelow_AnchorsFaceEachOther() + { + // Arrange: target stacked below the source + var from = Box(0, 0, 60, 60); + var to = Box(0, 200, 60, 60); + var boxes = new[] { from, to }; + + // Act + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: source leaves the bottom face, target is entered on the top face + var start = line.Waypoints[0]; + var end = line.Waypoints[^1]; + Assert.Equal(from.X + (from.Width / 2.0), start.X, 6); + Assert.Equal(from.Y + from.Height, start.Y, 6); // bottom face of source + Assert.Equal(to.X + (to.Width / 2.0), end.X, 6); + Assert.Equal(to.Y, end.Y, 6); // top face of target + } + + /// + /// A connector between two boxes routes around an intervening obstacle box without passing + /// through the obstacle's interior, while still producing an axis-aligned path. + /// + [Fact] + public void Route_ObstacleBetweenEndpoints_RoutesAroundInterior() + { + // Arrange: source and target on a shared horizontal band, with a tall box squarely between + var from = Box(0, 0, 60, 60); + var obstacle = Box(120, -40, 60, 140); + var to = Box(260, 0, 60, 60); + var boxes = new[] { from, obstacle, to }; + + // Act + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: the path is orthogonal and no segment crosses the intervening obstacle's interior + AssertAllSegmentsOrthogonal(line.Waypoints); + AssertNoSegmentCrossesObstacle(line.Waypoints, new Rect(obstacle.X, obstacle.Y, obstacle.Width, obstacle.Height)); + } + + /// + /// The two endpoint boxes are excluded from the obstacle set: the connector reaches their + /// boundary anchors even though both boxes are present in the box list. If they were treated + /// as obstacles, no clean approach to their faces would exist. + /// + [Fact] + public void Route_EndpointBoxes_AreExcludedFromObstacles() + { + // Arrange: two large adjacent boxes with only a narrow gap between their facing edges. The + // source anchor sits on the source's right face and the target anchor on the target's left + // face; both anchors lie on box boundaries that only route cleanly when the endpoints are + // excluded from the obstacle set. + var from = Box(0, 0, 120, 120); + var to = Box(140, 0, 120, 120); + var boxes = new[] { from, to }; + + // Act + var line = ConnectorRouter.Route(boxes, new Connection(from, to), new ConnectorRouteOptions()); + + // Assert: a clean, straight orthogonal hop between the facing faces (only possible because + // neither endpoint box is an obstacle) + AssertAllSegmentsOrthogonal(line.Waypoints); + Assert.Equal(from.X + from.Width, line.Waypoints[0].X, 6); + Assert.Equal(to.X, line.Waypoints[^1].X, 6); + + // Neither endpoint's interior is crossed, confirming the anchors reached the boundaries. + Assert.DoesNotContain(line.Waypoints, p => IsStrictlyInside(p, from) || IsStrictlyInside(p, to)); + } + + /// + /// The routed carries the connection's requested target end marker, + /// line style, and label, and always reports no source-end marker. + /// + [Fact] + public void Route_Connection_CarriesRequestedStyling() + { + // Arrange: a connection with an explicit arrowhead, dashed style, and a label + var from = Box(0, 0, 60, 60); + var to = Box(200, 0, 60, 60); + var boxes = new[] { from, to }; + var connection = new Connection(from, to, EndMarkerStyle.FilledArrow, LineStyle.Dashed, "supertype"); + + // Act + var line = ConnectorRouter.Route(boxes, connection, new ConnectorRouteOptions()); + + // Assert: the styling flows onto the line and the source end stays unmarked + Assert.Equal(EndMarkerStyle.None, line.SourceEnd); + Assert.Equal(EndMarkerStyle.FilledArrow, line.TargetEnd); + Assert.Equal(LineStyle.Dashed, line.LineStyle); + Assert.Equal("supertype", line.MidpointLabel); + } + + /// + /// The batch overload returns one routed line per connection, in input order. + /// + [Fact] + public void Route_MultipleConnections_ReturnsOneLinePerConnectionInOrder() + { + // Arrange: three boxes and two connections from the first box + var a = Box(0, 0, 60, 60); + var b = Box(200, 0, 60, 60); + var c = Box(0, 200, 60, 60); + var boxes = new[] { a, b, c }; + var connections = new[] + { + new Connection(a, b, EndMarkerStyle.FilledArrow), + new Connection(a, c, EndMarkerStyle.HollowDiamond), + }; + + // Act + var lines = ConnectorRouter.Route(boxes, connections, new ConnectorRouteOptions()); + + // Assert: two lines, in the same order, each carrying its own target marker + Assert.Equal(2, lines.Count); + Assert.Equal(EndMarkerStyle.FilledArrow, lines[0].TargetEnd); + Assert.Equal(EndMarkerStyle.HollowDiamond, lines[1].TargetEnd); + } + + /// + /// A null box list is rejected by the batch overload. + /// + [Fact] + public void Route_NullBoxes_Throws() + { + // Arrange + var from = Box(0, 0, 60, 60); + var to = Box(200, 0, 60, 60); + + // Act / Assert + Assert.Throws( + () => ConnectorRouter.Route(null!, new[] { new Connection(from, to) }, new ConnectorRouteOptions())); + } + + /// + /// A null connection list is rejected by the batch overload. + /// + [Fact] + public void Route_NullConnections_Throws() + { + // Arrange + var boxes = new[] { Box(0, 0, 60, 60) }; + + // Act / Assert + Assert.Throws( + () => ConnectorRouter.Route(boxes, (IReadOnlyList)null!, new ConnectorRouteOptions())); + } + + /// + /// A null options argument is rejected by the single-connection overload. + /// + [Fact] + public void Route_NullOptions_Throws() + { + // Arrange + var from = Box(0, 0, 60, 60); + var to = Box(200, 0, 60, 60); + var boxes = new[] { from, to }; + + // Act / Assert + Assert.Throws( + () => ConnectorRouter.Route(boxes, new Connection(from, to), null!)); + } + + /// + /// A null connection is rejected by the single-connection overload. + /// + [Fact] + public void Route_NullConnection_Throws() + { + // Arrange + var boxes = new[] { Box(0, 0, 60, 60) }; + + // Act / Assert + Assert.Throws( + () => ConnectorRouter.Route(boxes, (Connection)null!, new ConnectorRouteOptions())); + } + + /// + /// Asserts that every consecutive pair of waypoints forms a horizontal or vertical segment. + /// + private static void AssertAllSegmentsOrthogonal(IReadOnlyList path) + { + for (var i = 0; i < path.Count - 1; i++) + { + var a = path[i]; + var b = path[i + 1]; + var horizontal = Math.Abs(a.Y - b.Y) < 1e-6; + var vertical = Math.Abs(a.X - b.X) < 1e-6; + Assert.True(horizontal || vertical, + $"Segment {i} from ({a.X},{a.Y}) to ({b.X},{b.Y}) is not orthogonal."); + } + } + + /// + /// Asserts that no segment of the path passes through the strict interior of the obstacle. + /// + private static void AssertNoSegmentCrossesObstacle(IReadOnlyList path, Rect obstacle) + { + for (var i = 0; i < path.Count - 1; i++) + { + var a = path[i]; + var b = path[i + 1]; + Assert.False(SegmentCrossesRect(a, b, obstacle), + $"Segment {i} from ({a.X},{a.Y}) to ({b.X},{b.Y}) crosses obstacle."); + } + } + + /// Returns true when the point lies strictly inside the box. + private static bool IsStrictlyInside(Point2D p, LayoutBox box) => + box.X < p.X && p.X < box.X + box.Width && + box.Y < p.Y && p.Y < box.Y + box.Height; + + /// + /// Returns true when the axis-aligned segment passes through the strict interior of the rect. + /// + private static bool SegmentCrossesRect(Point2D a, Point2D b, Rect r) + { + if (Math.Abs(a.Y - b.Y) < 1e-6) + { + // Horizontal segment + var y = a.Y; + var xa = Math.Min(a.X, b.X); + var xb = Math.Max(a.X, b.X); + return r.Y < y && y < r.Y + r.Height && + Math.Max(xa, r.X) < Math.Min(xb, r.X + r.Width); + } + + // Vertical segment + var x = a.X; + var ya = Math.Min(a.Y, b.Y); + var yb = Math.Max(a.Y, b.Y); + return r.X < x && x < r.X + r.Width && + Math.Max(ya, r.Y) < Math.Min(yb, r.Y + r.Height); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs new file mode 100644 index 0000000..ba112ed --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs @@ -0,0 +1,236 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Layout.Tests; + +/// +/// Tests for the bundled , exercising the full +/// graph-to-placed-tree path: row packing of nodes plus routing of edges around them. +/// +public sealed class ContainmentLayoutAlgorithmTests +{ + /// + /// Proves that the algorithm advertises the stable "containment" identifier. + /// + [Fact] + public void Id_IsContainment() + { + // Act / Assert + Assert.Equal("containment", new ContainmentLayoutAlgorithm().Id); + } + + /// + /// Proves that the nodes are packed as non-overlapping boxes, in input order, entirely within + /// the returned canvas. + /// + [Fact] + public void Apply_Graph_PacksNodesNonOverlappingInInputOrderWithinCanvas() + { + // Arrange: a set of labelled peer nodes with no edges + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 80, 40); + var b = graph.AddNode("b", 120, 40); + var c = graph.AddNode("c", 60, 40); + var d = graph.AddNode("d", 90, 40); + a.Label = "A"; + b.Label = "B"; + c.Label = "C"; + d.Label = "D"; + + // Act + var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + // Assert: one box per node, emitted in input order + var boxes = tree.Nodes.OfType().ToList(); + Assert.Equal(4, boxes.Count); + Assert.Equal(["A", "B", "C", "D"], boxes.Select(box => box.Label)); + + // Every box lies fully within the reported canvas. + foreach (var box in boxes) + { + Assert.True(box.X >= 0); + Assert.True(box.Y >= 0); + Assert.True(box.X + box.Width <= tree.Width + 1e-9); + Assert.True(box.Y + box.Height <= tree.Height + 1e-9); + } + + // No two boxes overlap. + for (var i = 0; i < boxes.Count; i++) + { + for (var j = i + 1; j < boxes.Count; j++) + { + Assert.False(Overlaps(boxes[i], boxes[j]), $"Boxes {i} and {j} overlap."); + } + } + } + + /// + /// Proves that one connector is routed per input edge and that each carries the edge's target + /// end marker, line style, and label. + /// + [Fact] + public void Apply_Graph_RoutesOneConnectorPerEdgeCarryingStyling() + { + // Arrange: three nodes joined by two styled edges + 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); + var e1 = graph.AddEdge("e1", a, b); + e1.TargetEnd = EndMarkerStyle.FilledArrow; + e1.LineStyle = LineStyle.Dashed; + e1.Label = "owns"; + var e2 = graph.AddEdge("e2", a, c); + e2.TargetEnd = EndMarkerStyle.HollowDiamond; + + // Act + var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + // Assert: one line per edge, each carrying its styling and a routed polyline + var lines = tree.Nodes.OfType().ToList(); + Assert.Equal(2, lines.Count); + Assert.All(lines, line => Assert.True(line.Waypoints.Count >= 2)); + + Assert.Equal(EndMarkerStyle.FilledArrow, lines[0].TargetEnd); + Assert.Equal(LineStyle.Dashed, lines[0].LineStyle); + Assert.Equal("owns", lines[0].MidpointLabel); + Assert.Equal(EndMarkerStyle.None, lines[0].SourceEnd); + + Assert.Equal(EndMarkerStyle.HollowDiamond, lines[1].TargetEnd); + } + + /// + /// Proves that an edge whose endpoints are separated by an intervening box routes around that + /// box rather than through its interior. + /// + [Fact] + public void Apply_EdgeCrossingInterveningBox_RoutesAroundIt() + { + // Arrange: three wide nodes packed onto a single row so the middle node sits squarely between + // the first and third; the edge from the first to the third must steer around the middle box. + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 60, 240); + graph.AddNode("mid", 60, 240); + var b = graph.AddNode("b", 60, 240); + graph.AddEdge("e", a, b); + + // Act + var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + // Assert: the three boxes packed onto one row, with the middle box between the endpoints + var boxes = tree.Nodes.OfType().ToList(); + Assert.Equal(3, boxes.Count); + var interveningBox = boxes[1]; + Assert.Equal(boxes[0].Y, interveningBox.Y); + Assert.Equal(interveningBox.Y, boxes[2].Y); + Assert.True(boxes[0].X < interveningBox.X); + Assert.True(interveningBox.X < boxes[2].X); + + // The single routed connector avoids the intervening box's interior. + var line = Assert.Single(tree.Nodes.OfType()); + var obstacle = new Rect(interveningBox.X, interveningBox.Y, interveningBox.Width, interveningBox.Height); + for (var i = 0; i < line.Waypoints.Count - 1; i++) + { + Assert.False( + SegmentCrossesRect(line.Waypoints[i], line.Waypoints[i + 1], obstacle), + $"Segment {i} crosses the intervening box."); + } + } + + /// + /// Proves that an empty graph produces an empty, positively-sized canvas. + /// + [Fact] + public void Apply_EmptyGraph_ReturnsEmptyCanvas() + { + // Act + var tree = new ContainmentLayoutAlgorithm().Apply(new LayoutGraph(), new LayoutOptions()); + + // Assert + Assert.Empty(tree.Nodes); + Assert.True(tree.Width > 0); + Assert.True(tree.Height > 0); + } + + /// + /// Proves that an edge referencing a node outside the graph's top-level nodes is skipped. + /// + [Fact] + public void Apply_EdgeReferencingOutOfGraphNode_IsSkipped() + { + // Arrange: a graph with two top-level nodes and one edge between them, plus a second edge whose + // target is a descendant node that is not a top-level node of the graph. + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 80, 40); + var b = graph.AddNode("b", 80, 40); + graph.AddEdge("in", a, b); + + // A nested descendant node added to the LCA (root) graph as a cross-container edge endpoint. + var inner = a.Children.AddNode("inner", 40, 20); + graph.AddEdge("out", a, inner); + + // Act + var tree = new ContainmentLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + // Assert: only the in-graph edge is routed; the out-of-graph edge is skipped + var lines = tree.Nodes.OfType().ToList(); + Assert.Single(lines); + } + + /// + /// Proves that a null graph argument is rejected. + /// + [Fact] + public void Apply_NullGraph_Throws() + { + // Act / Assert + Assert.Throws( + () => new ContainmentLayoutAlgorithm().Apply(null!, new LayoutOptions())); + } + + /// + /// Proves that a null options argument is rejected. + /// + [Fact] + public void Apply_NullOptions_Throws() + { + // Act / Assert + Assert.Throws( + () => new ContainmentLayoutAlgorithm().Apply(new LayoutGraph(), null!)); + } + + /// + /// Determines whether two boxes overlap with a positive-area intersection. + /// + private static bool Overlaps(LayoutBox a, LayoutBox b) => + a.X < b.X + b.Width && + b.X < a.X + a.Width && + a.Y < b.Y + b.Height && + b.Y < a.Y + a.Height; + + /// + /// Returns true when the axis-aligned segment passes through the strict interior of the rect. + /// + private static bool SegmentCrossesRect(Point2D a, Point2D b, Rect r) + { + if (Math.Abs(a.Y - b.Y) < 1e-6) + { + // Horizontal segment + var y = a.Y; + var xa = Math.Min(a.X, b.X); + var xb = Math.Max(a.X, b.X); + return r.Y < y && y < r.Y + r.Height && + Math.Max(xa, r.X) < Math.Min(xb, r.X + r.Width); + } + + // Vertical segment + var x = a.X; + var ya = Math.Min(a.Y, b.Y); + var yb = Math.Max(a.Y, b.Y); + return r.X < x && x < r.X + r.Width && + Math.Max(ya, r.Y) < Math.Min(yb, r.Y + r.Height); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs new file mode 100644 index 0000000..a226e8a --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutTests.cs @@ -0,0 +1,243 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout; + +namespace DemaConsulting.Rendering.Layout.Tests; + +/// +/// Tests for public containment-packing of model boxes. +/// +public sealed class ContainmentLayoutTests +{ + /// + /// Creates a plain rectangular of the given size (placed at the origin, + /// which the packer overwrites) with no compartments or children. + /// + private static LayoutBox Box(double width, double height, string? label = null) => + new(0, 0, width, height, label, 0, BoxShape.Rectangle, [], []); + + /// + /// The packed children are returned in the same order they were supplied, laid out left to right + /// along a row when they all fit the content width. + /// + [Fact] + public void Pack_ItemsFitInRow_PreservesOrderLeftToRight() + { + // Arrange: three labelled boxes that together fit within a 300px content width + var children = new[] { Box(60, 40, "first"), Box(60, 40, "second"), Box(60, 40, "third") }; + + // Act: pack them into a single row + var result = ContainmentLayout.Pack(children, new ContainmentOptions(MaxContentWidth: 300)); + + // Assert: order is preserved and X increases strictly left to right on a shared row + Assert.Equal(["first", "second", "third"], result.Children.Select(c => c.Label)); + Assert.True(result.Children[0].X < result.Children[1].X); + Assert.True(result.Children[1].X < result.Children[2].X); + Assert.Equal(result.Children[0].Y, result.Children[1].Y); + Assert.Equal(result.Children[1].Y, result.Children[2].Y); + } + + /// + /// For a mixed-size set that spans multiple rows, no two packed children overlap. + /// + [Fact] + public void Pack_MixedSizes_ProducesNoOverlaps() + { + // Arrange: a varied mix of sizes that forces several rows within the width budget + var children = new[] + { + Box(60, 30), Box(120, 20), Box(40, 50), + Box(90, 25), Box(70, 40), Box(50, 30), + Box(110, 35), Box(30, 20), + }; + + // Act + var result = ContainmentLayout.Pack(children, new ContainmentOptions(MaxContentWidth: 250)); + + // Assert: every pair of placed boxes is disjoint + for (var i = 0; i < result.Children.Count; i++) + { + for (var j = i + 1; j < result.Children.Count; j++) + { + Assert.False(Overlaps(result.Children[i], result.Children[j]), + $"Boxes {i} and {j} overlap."); + } + } + } + + /// + /// Every packed child lies fully within the reported region bounds. + /// + [Fact] + public void Pack_MixedSizes_AllChildrenWithinRegion() + { + // Arrange: a mix of sizes + var children = new[] + { + Box(60, 30), Box(120, 20), Box(40, 50), + Box(90, 25), Box(70, 40), + }; + + // Act + var result = ContainmentLayout.Pack(children, new ContainmentOptions(MaxContentWidth: 200)); + + // Assert: each box is contained within [0, Width] x [0, Height] + foreach (var child in result.Children) + { + Assert.True(child.X >= 0); + Assert.True(child.Y >= 0); + Assert.True(child.X + child.Width <= result.Width + 1e-9); + Assert.True(child.Y + child.Height <= result.Height + 1e-9); + } + } + + /// + /// A child that would exceed the maximum content width wraps onto a new row below the current one. + /// + [Fact] + public void Pack_ChildExceedsWidth_WrapsToNewRow() + { + // Arrange: three 80-wide boxes; a 200px content width fits only two per row + var children = new[] { Box(80, 20), Box(80, 20), Box(80, 20) }; + + // Act + var result = ContainmentLayout.Pack(children, new ContainmentOptions(MaxContentWidth: 200)); + + // Assert: the first two share a row and the third wraps to a lower row at the left origin + Assert.Equal(result.Children[0].Y, result.Children[1].Y); + Assert.True(result.Children[2].Y > result.Children[0].Y); + Assert.Equal(result.Children[0].X, result.Children[2].X); + } + + /// + /// A child wider than the content width is placed alone and the region widens to contain it. + /// + [Fact] + public void Pack_OversizedChild_PlacedAloneAndRegionWidens() + { + // Arrange: a 300-wide box with only 100px of content width available + var children = new[] { Box(50, 20), Box(300, 20) }; + + // Act + var result = ContainmentLayout.Pack(children, new ContainmentOptions(MaxContentWidth: 100)); + + // Assert: the oversized box wrapped to its own row and the region widened to enclose it + Assert.True(result.Children[1].Y > result.Children[0].Y); + Assert.True(result.Width >= 300.0 + result.Children[1].X); + Assert.True(result.Children[1].X + result.Children[1].Width <= result.Width + 1e-9); + } + + /// + /// Packing no children yields a region consisting solely of padding, with no children returned. + /// + [Fact] + public void Pack_EmptyInput_ReturnsPaddingOnlyRegion() + { + // Act: pack an empty list with a padding of 15 + var result = ContainmentLayout.Pack([], new ContainmentOptions(MaxContentWidth: 100, Padding: 15)); + + // Assert: no children and a region of exactly 2*padding on each axis + Assert.Empty(result.Children); + Assert.Equal(30.0, result.Width); + Assert.Equal(30.0, result.Height); + } + + /// + /// Non-position fields of each child (label, depth, shape, compartments, children, keyword) survive + /// the pack unchanged; only X and Y are updated. + /// + [Fact] + public void Pack_PreservesNonPositionFields() + { + // Arrange: a richly populated box with a nested child and compartments + var nested = Box(10, 10, "inner"); + var compartments = new[] { new LayoutCompartment("attributes", ["speed: Real"]) }; + var original = new LayoutBox( + 999, 999, 90, 50, "Engine", 3, BoxShape.Folder, compartments, new[] { (LayoutNode)nested }, "part def"); + + // Act + var result = ContainmentLayout.Pack(new[] { original }, new ContainmentOptions(MaxContentWidth: 200, Padding: 12)); + + // Assert: position was updated to the padding origin while every other field is unchanged + var placed = Assert.Single(result.Children); + Assert.Equal(12.0, placed.X); + Assert.Equal(12.0, placed.Y); + Assert.Equal(90, placed.Width); + Assert.Equal(50, placed.Height); + Assert.Equal("Engine", placed.Label); + Assert.Equal(3, placed.Depth); + Assert.Equal(BoxShape.Folder, placed.Shape); + Assert.Same(compartments, placed.Compartments); + Assert.Equal("part def", placed.Keyword); + Assert.Same(nested, Assert.Single(placed.Children)); + } + + /// + /// The options record supplies sensible gap and padding defaults so a caller need only specify the + /// wrap width. + /// + [Fact] + public void ContainmentOptions_Defaults_AreSensibleGapsAndPadding() + { + // Act: construct options specifying only the required content width + var options = new ContainmentOptions(MaxContentWidth: 200); + + // Assert: the documented defaults apply + Assert.Equal(8.0, options.HorizontalGap); + Assert.Equal(8.0, options.VerticalGap); + Assert.Equal(12.0, options.Padding); + } + + /// + /// A null children list is rejected with an argument-null error. + /// + [Fact] + public void Pack_NullChildren_Throws() + { + // Act / Assert + Assert.Throws( + () => ContainmentLayout.Pack(null!, new ContainmentOptions(MaxContentWidth: 100))); + } + + /// + /// A null options argument is rejected with an argument-null error. + /// + [Fact] + public void Pack_NullOptions_Throws() + { + // Arrange + var children = new[] { Box(60, 40) }; + + // Act / Assert + Assert.Throws( + () => ContainmentLayout.Pack(children, null!)); + } + + /// + /// A null element within the children list is rejected with an argument-null error. + /// + [Fact] + public void Pack_NullChildElement_Throws() + { + // Arrange: a list containing a null box + var children = new LayoutBox[] { Box(60, 40), null! }; + + // Act / Assert + Assert.Throws( + () => ContainmentLayout.Pack(children, new ContainmentOptions(MaxContentWidth: 100))); + } + + /// + /// Determines whether two boxes overlap with a positive-area intersection. + /// + private static bool Overlaps(LayoutBox a, LayoutBox b) + { + return a.X < b.X + b.Width && + b.X < a.X + a.Width && + a.Y < b.Y + b.Height && + b.Y < a.Y + a.Height; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/DemaConsulting.Rendering.Layout.Tests.csproj b/test/DemaConsulting.Rendering.Layout.Tests/DemaConsulting.Rendering.Layout.Tests.csproj new file mode 100644 index 0000000..ed12a98 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/DemaConsulting.Rendering.Layout.Tests.csproj @@ -0,0 +1,67 @@ + + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + Exe + false + true + true + + + true + true + true + latest + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/test/DemaConsulting.Rendering.Layout.Tests/EdgeRoutingOptionTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/EdgeRoutingOptionTests.cs new file mode 100644 index 0000000..d2a4aa3 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/EdgeRoutingOptionTests.cs @@ -0,0 +1,88 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout; + +namespace DemaConsulting.Rendering.Layout.Tests; + +/// +/// Tests for the routing-style option: the +/// property key, its participation in the open property system, and the +/// defaults. +/// +public sealed class EdgeRoutingOptionTests +{ + /// + /// The key defaults to + /// , the only shipped routing style. + /// + [Fact] + public void CoreOptions_EdgeRouting_DefaultsToOrthogonal() + { + // Assert: the well-known key advertises the orthogonal default + Assert.Equal(EdgeRouting.Orthogonal, CoreOptions.EdgeRouting.DefaultValue); + } + + /// + /// The key carries the ELK-flavored identifier + /// rendering.edgerouting. + /// + [Fact] + public void CoreOptions_EdgeRouting_HasStableId() + { + // Assert: the stable dotted id mirrors elk.edgeRouting + Assert.Equal("rendering.edgerouting", CoreOptions.EdgeRouting.Id); + } + + /// + /// The routing style rides the open property system: it can be selected per scope by setting + /// the key on any and read back, exactly like the algorithm key. + /// + [Fact] + public void CoreOptions_EdgeRouting_SelectablePerScope() + { + // Arrange: a free-standing options holder + var options = new LayoutOptions(); + + // Act: select the routing style on the holder, then read it back + options.Set(CoreOptions.EdgeRouting, EdgeRouting.Orthogonal); + + // Assert: the value round-trips through the property system + Assert.True(options.Contains(CoreOptions.EdgeRouting)); + Assert.Equal(EdgeRouting.Orthogonal, options.Get(CoreOptions.EdgeRouting)); + } + + /// + /// An unset holder returns the orthogonal default for the routing key, so callers that never + /// select a style still route orthogonally. + /// + [Fact] + public void CoreOptions_EdgeRouting_UnsetReturnsDefault() + { + // Arrange: an options holder with no routing selection + var options = new LayoutOptions(); + + // Assert: the key falls back to its orthogonal default + Assert.False(options.Contains(CoreOptions.EdgeRouting)); + Assert.Equal(EdgeRouting.Orthogonal, options.Get(CoreOptions.EdgeRouting)); + } + + /// + /// defaults to orthogonal routing with a 12-pixel + /// clearance, and callers can override the clearance. + /// + [Fact] + public void ConnectorRouteOptions_Defaults_AreOrthogonalWithTwelvePixelClearance() + { + // Act: construct with defaults, then with an explicit clearance override + var defaults = new ConnectorRouteOptions(); + var overridden = new ConnectorRouteOptions(Clearance: 20.0); + + // Assert: orthogonal default style, 12px default clearance, and honored override + Assert.Equal(EdgeRouting.Orthogonal, defaults.EdgeRouting); + Assert.Equal(12.0, defaults.Clearance); + Assert.Equal(20.0, overridden.Clearance); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/ContainmentPackerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/ContainmentPackerTests.cs new file mode 100644 index 0000000..0a001fb --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/ContainmentPackerTests.cs @@ -0,0 +1,172 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine; + +/// +/// Tests for shelf bin-packing. +/// +public sealed class ContainmentPackerTests +{ + /// + /// Packing an empty list returns a region consisting only of padding and no rectangles. + /// + [Fact] + public void Pack_EmptyList_ReturnsPaddingOnlyRegion() + { + // Act: pack no items with padding 10 + var result = ContainmentPacker.Pack([], maxContentWidth: 100, horizontalGap: 5, verticalGap: 5, padding: 10); + + // Assert: region is 2*padding on each axis with no rectangles + Assert.Empty(result.Rects); + Assert.Equal(20.0, result.Width); + Assert.Equal(20.0, result.Height); + } + + /// + /// A single item is positioned at the padding origin and the region fits it exactly. + /// + [Fact] + public void Pack_SingleItem_PositionsAtPaddingOrigin() + { + // Arrange: one 40x20 item + var items = new[] { new PackItem(40, 20) }; + + // Act + var result = ContainmentPacker.Pack(items, maxContentWidth: 200, horizontalGap: 5, verticalGap: 5, padding: 10); + + // Assert: positioned at (10, 10); region = item + 2*padding + Assert.Single(result.Rects); + Assert.Equal(10.0, result.Rects[0].X); + Assert.Equal(10.0, result.Rects[0].Y); + Assert.Equal(60.0, result.Width); + Assert.Equal(40.0, result.Height); + } + + /// + /// Items that fit within the max content width are placed on a single row sharing a Y. + /// + [Fact] + public void Pack_ItemsFitInRow_ShareSameRow() + { + // Arrange: three 30-wide items; max content width 200 fits all in one row + var items = new[] { new PackItem(30, 20), new PackItem(30, 20), new PackItem(30, 20) }; + + // Act + var result = ContainmentPacker.Pack(items, maxContentWidth: 200, horizontalGap: 5, verticalGap: 5, padding: 10); + + // Assert: all three share the same top Y (single row) + Assert.Equal(result.Rects[0].Y, result.Rects[1].Y); + Assert.Equal(result.Rects[1].Y, result.Rects[2].Y); + + // And X positions increase left-to-right with the horizontal gap + Assert.Equal(10.0, result.Rects[0].X); + Assert.Equal(45.0, result.Rects[1].X); + Assert.Equal(80.0, result.Rects[2].X); + } + + /// + /// Items exceeding the max content width wrap to a new row positioned below the first. + /// + [Fact] + public void Pack_ItemsExceedWidth_WrapToNewRow() + { + // Arrange: three 80-wide items; max content width 200 fits only two per row + var items = new[] { new PackItem(80, 20), new PackItem(80, 20), new PackItem(80, 20) }; + + // Act + var result = ContainmentPacker.Pack(items, maxContentWidth: 200, horizontalGap: 5, verticalGap: 5, padding: 10); + + // Assert: first two on row 0, third wraps to row 1 with a greater Y + Assert.Equal(result.Rects[0].Y, result.Rects[1].Y); + Assert.True(result.Rects[2].Y > result.Rects[0].Y); + + // Third item starts a new row at the left padding origin + Assert.Equal(10.0, result.Rects[2].X); + } + + /// + /// For a mixed-size set, no two packed rectangles overlap. + /// + [Fact] + public void Pack_MixedSizes_ProducesNoOverlaps() + { + // Arrange: a varied mix of sizes that forces multiple rows + var items = new[] + { + new PackItem(60, 30), new PackItem(120, 20), new PackItem(40, 50), + new PackItem(90, 25), new PackItem(70, 40), new PackItem(50, 30), + new PackItem(110, 35), new PackItem(30, 20), + }; + + // Act + var result = ContainmentPacker.Pack(items, maxContentWidth: 250, horizontalGap: 8, verticalGap: 8, padding: 12); + + // Assert: every pair of rectangles is disjoint + for (var i = 0; i < result.Rects.Count; i++) + { + for (var j = i + 1; j < result.Rects.Count; j++) + { + Assert.False(Overlaps(result.Rects[i], result.Rects[j]), + $"Rectangles {i} and {j} overlap."); + } + } + } + + /// + /// Every packed rectangle lies fully within the reported region bounds. + /// + [Fact] + public void Pack_MixedSizes_AllRectsWithinBounds() + { + // Arrange: a varied mix of sizes + var items = new[] + { + new PackItem(60, 30), new PackItem(120, 20), new PackItem(40, 50), + new PackItem(90, 25), new PackItem(70, 40), + }; + + // Act + var result = ContainmentPacker.Pack(items, maxContentWidth: 200, horizontalGap: 8, verticalGap: 8, padding: 12); + + // Assert: each rectangle is contained within [0, Width] x [0, Height] + foreach (var r in result.Rects) + { + Assert.True(r.X >= 0); + Assert.True(r.Y >= 0); + Assert.True(r.X + r.Width <= result.Width + 1e-9); + Assert.True(r.Y + r.Height <= result.Height + 1e-9); + } + } + + /// + /// An item wider than the content width is placed alone and the region widens to fit it. + /// + [Fact] + public void Pack_ItemWiderThanContentWidth_PlacedAloneAndRegionWidens() + { + // Arrange: a 300-wide item with only 100 content width available + var items = new[] { new PackItem(50, 20), new PackItem(300, 20) }; + + // Act + var result = ContainmentPacker.Pack(items, maxContentWidth: 100, horizontalGap: 5, verticalGap: 5, padding: 10); + + // Assert: the oversized item wrapped to its own row and the region widened to contain it + Assert.True(result.Rects[1].Y > result.Rects[0].Y); + Assert.True(result.Width >= 320.0); + } + + /// + /// Determines whether two rectangles overlap with a positive-area intersection. + /// + private static bool Overlaps(PackedRect a, PackedRect b) + { + return a.X < b.X + b.Width && + b.X < a.X + a.Width && + a.Y < b.Y + b.Height && + b.Y < a.Y + a.Height; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/InterconnectionLayoutEngineTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/InterconnectionLayoutEngineTests.cs new file mode 100644 index 0000000..c3f5eb5 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/InterconnectionLayoutEngineTests.cs @@ -0,0 +1,172 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine; + +/// +/// Tests for covering longest-path layering, +/// dummy-node routing for long edges, non-overlapping placement, and connector waypoints. +/// +public sealed class InterconnectionLayoutEngineTests +{ + /// + /// A simple directed chain A(0)→B(1)→C(2) produces three monotonically increasing + /// layer indices: 0, 1, 2. Longest-path layering assigns each node to the length + /// of the longest path from any source. + /// + [Fact] + public void Place_LinearChain_MonotonicLayerAssignment() + { + var nodes = new List { new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(1, 2) }; + + var result = InterconnectionLayoutEngine.Place(nodes, edges); + + Assert.Equal(0, result.NodeLayers[0]); + Assert.Equal(1, result.NodeLayers[1]); + Assert.Equal(2, result.NodeLayers[2]); + } + + /// + /// A span-1 edge between two equal-height nodes produces exactly two waypoints + /// (a straight horizontal path): source-right-port and target-left-port. + /// The Brandes-Köpf algorithm aligns the two nodes' ports at the same Y so no + /// bend points are needed. + /// + [Fact] + public void Place_SingleEdge_ProducesStraightTwoWaypointPath() + { + var nodes = new List { new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1) }; + + var result = InterconnectionLayoutEngine.Place(nodes, edges); + + Assert.Single(result.ConnectorWaypoints); + Assert.Equal(2, result.ConnectorWaypoints[0].Count); + } + + /// + /// The rect count always equals the number of input nodes — long edges use dummy-node + /// routing through intermediate layer gaps, not additional real boxes. + /// A diamond topology with an extra 0→3 long edge produces exactly four rects and + /// five waypoint lists. + /// + [Fact] + public void Place_LongEdge_RectCountEqualsInputNodeCount() + { + // 0→1→3 and 0→2→3 place: 0 at layer 0, 1/2 at layer 1, 3 at layer 2. + // Adding 0→3 creates a span-2 long edge routed via a dummy node. + var nodes = new List + { + new(80, 50), + new(80, 50), + new(80, 50), + new(80, 50), + }; + var edges = new List + { + new(0, 1), + new(0, 2), + new(1, 3), + new(2, 3), + new(0, 3), // span-2: one dummy node in layer 1 + }; + + var result = InterconnectionLayoutEngine.Place(nodes, edges); + + Assert.Equal(4, result.Rects.Count); + Assert.Equal(5, result.ConnectorWaypoints.Count); + } + + /// + /// A long edge (span > 1) is routed via dummy nodes in intermediate layers. + /// A span-3 edge uses two dummies, producing at least four waypoints, all within + /// the diagram bounds. + /// + [Fact] + public void Place_LongEdge_RoutesViaDummyNodesWithinBounds() + { + // Chain 0→1→2→3 sets layers 0,1,2,3; adding 0→3 spans 3 layers. + var nodes = Enumerable.Repeat(new LayerNode(80, 50), 4).ToList(); + var edges = new List + { + new(0, 1), + new(1, 2), + new(2, 3), + new(0, 3), // span-3: two dummy nodes in layers 1 and 2 + }; + + var result = InterconnectionLayoutEngine.Place(nodes, edges); + + var longEdgeWp = result.ConnectorWaypoints[3]; + + // A span-3 long edge has 3 sub-edges, each contributing 0–2 bend points. + // Minimum total: 2 (source port + target port); at least 4 when any sub-edge is non-straight. + Assert.True(longEdgeWp.Count >= 4, $"Expected at least 4 waypoints for a span-3 long edge, got {longEdgeWp.Count}."); + + // All waypoints must lie within the diagram bounds. + foreach (var wp in longEdgeWp) + { + Assert.InRange(wp.X, 0.0, result.TotalWidth); + Assert.InRange(wp.Y, 0.0, result.TotalHeight); + } + } + + /// + /// The Workstation topology (7 parts, 8 connections) produces the exact layer + /// assignments that longest-path layering guarantees and all seven rects are + /// non-overlapping. + /// + [Fact] + public void Place_WorkstationTopology_CorrectLayersAndNoOverlap() + { + // Part order matches SysML file: cpu=0 memory=1 graphics=2 storage=3 psu=4 network=5 board=6 + var nodes = Enumerable.Repeat(new LayerNode(130, 60), 7).ToList(); + var edges = new List + { + new(6, 0), // board → cpu + new(6, 1), // board → memory + new(6, 2), // board → graphics + new(6, 3), // board → storage + new(6, 5), // board → network + new(4, 6), // psu → board + new(4, 2), // psu → graphics + new(0, 1), // cpu → memory + }; + + var result = InterconnectionLayoutEngine.Place(nodes, edges); + + // Longest-path layers. + Assert.Equal(0, result.NodeLayers[4]); // psu: no incoming + Assert.Equal(1, result.NodeLayers[6]); // board: psu → board + Assert.Equal(2, result.NodeLayers[0]); // cpu: board → cpu + Assert.Equal(2, result.NodeLayers[2]); // graphics: max(psu→2, board→2) = 2 + Assert.Equal(2, result.NodeLayers[3]); // storage: board → storage + Assert.Equal(2, result.NodeLayers[5]); // network: board → network + Assert.Equal(3, result.NodeLayers[1]); // memory: max(board→2+1, cpu→2+1) = 3 + + // Output counts. + Assert.Equal(7, result.Rects.Count); + Assert.Equal(8, result.ConnectorWaypoints.Count); + + // No two rects overlap. + for (var i = 0; i < result.Rects.Count; i++) + { + for (var j = i + 1; j < result.Rects.Count; j++) + { + Assert.False( + Overlaps(result.Rects[i], result.Rects[j]), + $"Rects {i} and {j} overlap: {result.Rects[i]} / {result.Rects[j]}"); + } + } + } + + private static bool Overlaps(Rect a, Rect b) => + a.X < b.X + b.Width && + b.X < a.X + a.Width && + a.Y < b.Y + b.Height && + b.Y < a.Y + a.Height; +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/AxisTransformTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/AxisTransformTests.cs new file mode 100644 index 0000000..51e67a2 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/AxisTransformTests.cs @@ -0,0 +1,195 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering the left-to-right identity behavior and the +/// coordinate mapping, port-face emergence, and waypoint orthogonality for all four layout +/// directions. +/// +public sealed class AxisTransformTests +{ + private const double Tolerance = 1e-6; + private const double NodeWidth = 60.0; + private const double NodeHeight = 40.0; + + /// + /// With the left-to-right direction, the transform is an identity: the placed coordinates + /// are unchanged after it runs. + /// + [Fact] + public void AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged() + { + // Arrange: place a chain (left-to-right) up to but not including the axis transform. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(1, 2) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + new CrossingMinimizer().Apply(graph); + new BrandesKopfPlacer().Apply(graph); + new PortDistributor().Apply(graph); + new LayeredCorridorRouter().Apply(graph); + new LongEdgeJoiner().Apply(graph); + + var beforeX = (double[])graph.AugX.Clone(); + var beforeY = (double[])graph.AugY.Clone(); + + // Act: apply the identity transform. + new AxisTransform().Apply(graph); + + // Assert: coordinates are unchanged. + Assert.Equal(beforeX, graph.AugX); + Assert.Equal(beforeY, graph.AugY); + } + + /// + /// RIGHT places the target in a later layer to the right of the source; the source exits its + /// EAST face and the target enters its WEST face. + /// + [Fact] + public void AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces() + { + var graph = PlaceChain(LayoutDirection.Right); + + // Coordinate mapping: target sits to the right of the source. + Assert.True(graph.AugX[1] > graph.AugX[0]); + + // Port faces: source on its EAST (right) face, target on its WEST (left) face. + var (source, target) = EndpointsOf(graph, 0); + Assert.Equal(Right(graph, 0), source.X, Tolerance); + Assert.Equal(Left(graph, 1), target.X, Tolerance); + } + + /// + /// DOWN places the target in a later layer below the source; the source exits its SOUTH face + /// and the target enters its NORTH face. + /// + [Fact] + public void AxisTransform_Apply_Down_PlacesTargetSouthWithCorrectFaces() + { + var graph = PlaceChain(LayoutDirection.Down); + + // Coordinate mapping: target sits below the source. + Assert.True(graph.AugY[1] > graph.AugY[0]); + + // Port faces: source on its SOUTH (bottom) face, target on its NORTH (top) face. + var (source, target) = EndpointsOf(graph, 0); + Assert.Equal(Bottom(graph, 0), source.Y, Tolerance); + Assert.Equal(Top(graph, 1), target.Y, Tolerance); + } + + /// + /// LEFT places the target in a later layer to the left of the source; the source exits its + /// WEST face and the target enters its EAST face. + /// + [Fact] + public void AxisTransform_Apply_Left_PlacesTargetWestWithCorrectFaces() + { + var graph = PlaceChain(LayoutDirection.Left); + + // Coordinate mapping: target sits to the left of the source. + Assert.True(graph.AugX[1] < graph.AugX[0]); + + // Port faces: source on its WEST (left) face, target on its EAST (right) face. + var (source, target) = EndpointsOf(graph, 0); + Assert.Equal(Left(graph, 0), source.X, Tolerance); + Assert.Equal(Right(graph, 1), target.X, Tolerance); + } + + /// + /// UP places the target in a later layer above the source; the source exits its NORTH face + /// and the target enters its SOUTH face. + /// + [Fact] + public void AxisTransform_Apply_Up_PlacesTargetNorthWithCorrectFaces() + { + var graph = PlaceChain(LayoutDirection.Up); + + // Coordinate mapping: target sits above the source. + Assert.True(graph.AugY[1] < graph.AugY[0]); + + // Port faces: source on its NORTH (top) face, target on its SOUTH (bottom) face. + var (source, target) = EndpointsOf(graph, 0); + Assert.Equal(Top(graph, 0), source.Y, Tolerance); + Assert.Equal(Bottom(graph, 1), target.Y, Tolerance); + } + + /// The routed waypoints remain orthogonal after a RIGHT transform. + [Fact] + public void AxisTransform_Apply_Right_ProducesOrthogonalWaypoints() => + AssertWaypointsOrthogonal(LayoutDirection.Right); + + /// The routed waypoints remain orthogonal after a DOWN transform. + [Fact] + public void AxisTransform_Apply_Down_ProducesOrthogonalWaypoints() => + AssertWaypointsOrthogonal(LayoutDirection.Down); + + /// The routed waypoints remain orthogonal after a LEFT transform. + [Fact] + public void AxisTransform_Apply_Left_ProducesOrthogonalWaypoints() => + AssertWaypointsOrthogonal(LayoutDirection.Left); + + /// The routed waypoints remain orthogonal after an UP transform. + [Fact] + public void AxisTransform_Apply_Up_ProducesOrthogonalWaypoints() => + AssertWaypointsOrthogonal(LayoutDirection.Up); + + /// + /// Asserts every consecutive segment of the routed polyline is axis-aligned for the given + /// direction. + /// + /// The layout direction under test. + private static void AssertWaypointsOrthogonal(LayoutDirection direction) + { + var graph = PlaceChain(direction); + + var polyline = graph.Waypoints[0]; + for (var i = 0; i + 1 < polyline.Count; i++) + { + var dx = Math.Abs(polyline[i + 1].X - polyline[i].X); + var dy = Math.Abs(polyline[i + 1].Y - polyline[i].Y); + Assert.True( + dx < Tolerance || dy < Tolerance, + $"Segment {i} is not axis-aligned for {direction}: dx={dx}, dy={dy}."); + } + } + + /// Runs the full default pipeline for a two-node chain in the given direction. + private static LayeredGraph PlaceChain(LayoutDirection direction) + { + var nodes = new List { new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight) }; + var edges = new List { new(0, 1) }; + var graph = new LayeredGraph(nodes, edges, direction); + var pipeline = LayeredLayoutPipeline.Builder() + .Direction(direction) + .Hierarchy(global::DemaConsulting.Rendering.Layout.Engine.Layered.HierarchyHandling.Flat) + .AddDefaultStages() + .Build(); + pipeline.Run(graph); + return graph; + } + + /// Returns the first (source) and last (target) waypoint of the given edge. + private static (Point2D Source, Point2D Target) EndpointsOf(LayeredGraph graph, int edge) + { + var polyline = graph.Waypoints[edge]; + return (polyline[0], polyline[^1]); + } + + // Node screen rects are always the intrinsic node size drawn at the transformed top-left. + private static double Left(LayeredGraph graph, int node) => graph.AugX[node]; + + private static double Right(LayeredGraph graph, int node) => graph.AugX[node] + NodeWidth; + + private static double Top(LayeredGraph graph, int node) => graph.AugY[node]; + + private static double Bottom(LayeredGraph graph, int node) => graph.AugY[node] + NodeHeight; +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BrandesKopfPlacerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BrandesKopfPlacerTests.cs new file mode 100644 index 0000000..32a42de --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BrandesKopfPlacerTests.cs @@ -0,0 +1,95 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering assignment of finite coordinate arrays +/// and left-to-right column placement in layer order. +/// +public sealed class BrandesKopfPlacerTests +{ + /// + /// A chain (0->1->2) receives a finite X and Y for every augmented node and per-column + /// arrays sized to the number of layers. + /// + [Fact] + public void BrandesKopfPlacer_Apply_ChainGraph_AssignsCoordinateArrays() + { + // Arrange / Act: place a three-node chain. + var graph = BuildPlacedGraph( + new List { new(60, 40), new(60, 40), new(60, 40) }, + new List { new(0, 1), new(1, 2) }); + + // Assert: coordinate arrays are sized correctly and finite. + Assert.Equal(graph.AugNodes.Count, graph.AugX.Length); + Assert.Equal(graph.AugNodes.Count, graph.AugY.Length); + Assert.Equal(graph.Groups.Count, graph.ColumnX.Length); + Assert.Equal(graph.Groups.Count, graph.MaxColWidth.Length); + Assert.All(graph.AugX, x => Assert.True(double.IsFinite(x))); + Assert.All(graph.AugY, y => Assert.True(double.IsFinite(y))); + } + + /// + /// Layer columns are placed left to right, so each column's left edge is strictly greater + /// than the previous column's left edge. + /// + [Fact] + public void BrandesKopfPlacer_Apply_ColumnsAreLeftToRightInLayerOrder() + { + // Arrange / Act: place a three-node chain (three layers). + var graph = BuildPlacedGraph( + new List { new(60, 40), new(60, 40), new(60, 40) }, + new List { new(0, 1), new(1, 2) }); + + // Assert: column left edges strictly increase. + for (var l = 1; l < graph.ColumnX.Length; l++) + { + Assert.True( + graph.ColumnX[l] > graph.ColumnX[l - 1], + $"Column {l} left edge {graph.ColumnX[l]} is not right of column {l - 1} ({graph.ColumnX[l - 1]})."); + } + } + + /// + /// A symmetric fork (0->1, 0->2) places the source vertically centered between its two + /// targets, proving the four-pass balanced median placement rather than a naive single-pass + /// (e.g. top-aligned) placement, which would leave the source level with the first target. + /// + [Fact] + public void BrandesKopfPlacer_Apply_SymmetricFork_CentersSourceBetweenTargets() + { + // Arrange / Act: one source fanning out to two equal-size targets in the next layer. + var graph = BuildPlacedGraph( + new List { new(60, 40), new(60, 40), new(60, 40) }, + new List { new(0, 1), new(0, 2) }); + + // The two targets occupy layer 1; the source occupies layer 0. + var sourceY = graph.AugY[0]; + var targetMidpoint = (graph.AugY[1] + graph.AugY[2]) / 2.0; + + // Assert: the balanced placement centers the source between its two targets, and the two + // targets are genuinely separated (so the centering is a non-trivial result). + Assert.NotEqual(graph.AugY[1], graph.AugY[2]); + Assert.Equal(targetMidpoint, sourceY, 3); + } + + /// Runs the stages up to and including placement and returns the graph. + /// Input nodes. + /// Input edges. + /// The graph after the Brandes-Köpf placement stage. + private static LayeredGraph BuildPlacedGraph(List nodes, List edges) + { + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + new CrossingMinimizer().Apply(graph); + new BrandesKopfPlacer().Apply(graph); + return graph; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/ComponentPackerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/ComponentPackerTests.cs new file mode 100644 index 0000000..622e8dc --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/ComponentPackerTests.cs @@ -0,0 +1,348 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering connected-component detection, non-overlapping +/// packing of disconnected components, the single-component pass-through, the empty/null guards, +/// deterministic ordering, and translation of edge waypoints with their owning component. +/// +public sealed class ComponentPackerTests +{ + /// Logical pixel size used for every test node. + private const double NodeWidth = 60.0; + + /// Logical pixel height used for every test node. + private const double NodeHeight = 40.0; + + /// + /// A connected core (chain 0->1->2) plus a disconnected node keeps the core as one + /// component: the core's internal arrangement matches laying the core out on its own. + /// + [Fact] + public void ComponentPacker_Apply_ConnectedCore_StaysOneComponent() + { + // Arrange: a connected chain {0,1,2} and an isolated node 3. + var nodes = new List { new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight) }; + var edges = new List { new(0, 1), new(1, 2) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act: pack the disconnected graph. + ComponentPacker.WithDefaultStages().Apply(graph); + + // Reference: lay the connected core out on its own and normalize to its bounding box. + var coreNodes = new List { new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight) }; + var coreEdges = new List { new(0, 1), new(1, 2) }; + var core = RunDefaultStages(coreNodes, coreEdges); + var coreMinX = new[] { core.AugX[0], core.AugX[1], core.AugX[2] }.Min(); + var coreMinY = new[] { core.AugY[0], core.AugY[1], core.AugY[2] }.Min(); + + // The packed core is component 0 (offset 0,0), so its coordinates are already normalized. + for (var i = 0; i < 3; i++) + { + Assert.Equal(core.AugX[i] - coreMinX, graph.AugX[i], 6); + Assert.Equal(core.AugY[i] - coreMinY, graph.AugY[i], 6); + } + } + + /// + /// Three disconnected nodes become three components placed in distinct, non-overlapping boxes. + /// + [Fact] + public void ComponentPacker_Apply_DisconnectedSingletons_PackSeparately() + { + // Arrange: three isolated nodes, no edges. + var nodes = new List { new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight) }; + var graph = new LayeredGraph(nodes, [], LayoutDirection.Right); + + // Act. + ComponentPacker.WithDefaultStages().Apply(graph); + + // Assert: no two node boxes overlap. + for (var a = 0; a < nodes.Count; a++) + { + for (var b = a + 1; b < nodes.Count; b++) + { + Assert.False( + BoxesOverlap(graph, a, b), + $"nodes {a} and {b} should not overlap"); + } + } + } + + /// + /// A single connected component is a transparent pass-through: the packed output equals the + /// output of running the same inner stages directly on the graph. + /// + [Fact] + public void ComponentPacker_Apply_SingleComponent_EqualsDefaultPipeline() + { + // Arrange: a diamond (one connected component). + var nodes = new List { new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight) }; + var edges = new List { new(0, 1), new(0, 2), new(1, 3), new(2, 3) }; + + var packed = new LayeredGraph(CloneNodes(nodes), CloneEdges(edges), LayoutDirection.Right); + var reference = RunDefaultStages(CloneNodes(nodes), CloneEdges(edges)); + + // Act. + ComponentPacker.WithDefaultStages().Apply(packed); + + // Assert: node coordinates and routed waypoints match the default pipeline exactly. + for (var i = 0; i < nodes.Count; i++) + { + Assert.Equal(reference.AugX[i], packed.AugX[i]); + Assert.Equal(reference.AugY[i], packed.AugY[i]); + } + + Assert.Equal(reference.Waypoints.Count, packed.Waypoints.Count); + for (var k = 0; k < reference.Waypoints.Count; k++) + { + Assert.Equal(reference.Waypoints[k].Count, packed.Waypoints[k].Count); + for (var p = 0; p < reference.Waypoints[k].Count; p++) + { + Assert.Equal(reference.Waypoints[k][p].X, packed.Waypoints[k][p].X); + Assert.Equal(reference.Waypoints[k][p].Y, packed.Waypoints[k][p].Y); + } + } + } + + /// An empty graph is laid out as a no-op without throwing. + [Fact] + public void ComponentPacker_Apply_EmptyGraph_IsNoOp() + { + // Arrange: a graph with no nodes or edges. + var graph = new LayeredGraph([], [], LayoutDirection.Right); + + // Act. + ComponentPacker.WithDefaultStages().Apply(graph); + + // Assert: nothing was produced. + Assert.Empty(graph.AugX); + Assert.Empty(graph.AugY); + Assert.Empty(graph.Waypoints); + } + + /// A null graph throws . + [Fact] + public void ComponentPacker_Apply_NullGraph_Throws() + { + Assert.Throws(() => ComponentPacker.WithDefaultStages().Apply(null!)); + } + + /// + /// Repeated layouts of the same disconnected graph produce identical coordinates, confirming a + /// deterministic component order. + /// + [Fact] + public void ComponentPacker_Apply_ComponentOrder_IsDeterministic() + { + // Arrange: two components — an edge {0,1} and an isolated node 2. + static LayeredGraph Build() + { + var nodes = new List { new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight) }; + var edges = new List { new(0, 1) }; + return new LayeredGraph(nodes, edges, LayoutDirection.Right); + } + + var first = Build(); + var second = Build(); + + // Act. + ComponentPacker.WithDefaultStages().Apply(first); + ComponentPacker.WithDefaultStages().Apply(second); + + // Assert: identical placements. + for (var i = 0; i < 3; i++) + { + Assert.Equal(first.AugX[i], second.AugX[i]); + Assert.Equal(first.AugY[i], second.AugY[i]); + } + } + + /// + /// Each edge's routed waypoints are translated with its component so the endpoints stay on the + /// boxes of the offset component, not at the local origin. + /// + [Fact] + public void ComponentPacker_Apply_Waypoints_TranslatedWithComponent() + { + // Arrange: two components, each with one internal edge: {0->1} and {2->3}. + var nodes = new List { new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight) }; + var edges = new List { new(0, 1), new(2, 3) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act. + ComponentPacker.WithDefaultStages().Apply(graph); + + // Assert: every edge's first/last waypoint lies on its source/target box (within tolerance). + const double eps = 3.0; + AssertWaypointOnBox(graph, edge: 0, point: 0, node: 0, eps); + AssertWaypointOnBox(graph, edge: 0, point: -1, node: 1, eps); + AssertWaypointOnBox(graph, edge: 1, point: 0, node: 2, eps); + AssertWaypointOnBox(graph, edge: 1, point: -1, node: 3, eps); + } + + /// + /// A single connected component containing a parallel edge and a self-loop lays out without + /// throwing. The pipeline drops the self-loop and de-duplicates the parallel pair, so the + /// produced Waypoints are per-acyclic-edge and index-aligned with + /// — the same contract as the default pipeline — and every input edge resolves by its endpoints. + /// + [Fact] + public void ComponentPacker_Apply_SingleComponent_ParallelAndSelfEdges_ProducesAlignedWaypoints() + { + // Arrange: one connected component {0,1,2} with a parallel 0->1 pair, a 1->1 self-loop, and 1->2. + var nodes = new List { new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight) }; + var edges = new List { new(0, 1), new(0, 1), new(1, 1), new(1, 2) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act: must not throw despite the parallel pair and self-loop. + ComponentPacker.WithDefaultStages().Apply(graph); + + // Assert: Waypoints are index-aligned with the acyclic edge set (the routing contract). + Assert.Equal(graph.Acyclic.Count, graph.Waypoints.Count); + + // Assert: the self-loop is dropped and the parallel pair collapses to a single acyclic edge. + Assert.DoesNotContain(graph.Acyclic, e => e.Source == e.Target); + Assert.Equal(2, graph.Acyclic.Count); + Assert.Contains(graph.Acyclic, e => e.Source == 0 && e.Target == 1); + Assert.Contains(graph.Acyclic, e => e.Source == 1 && e.Target == 2); + + // Assert: every non-self input edge (including the duplicate) resolves to a routed polyline whose + // endpoints lie on the correct boxes. + var routed = BuildRouted(graph); + const double eps = 3.0; + AssertEndpointsOnBoxes(graph, routed[(0, 1)], 0, 1, eps); + AssertEndpointsOnBoxes(graph, routed[(1, 2)], 1, 2, eps); + } + + /// + /// Two connected components, each containing a parallel edge and a self-loop, lay out without + /// throwing (the multi-component merge path). The merged graph exposes one Waypoint per acyclic + /// edge, index-aligned with , and each edge's polyline is + /// translated onto its own component's (offset) boxes. + /// + [Fact] + public void ComponentPacker_Apply_MultiComponent_ParallelAndSelfEdges_MergesAlignedWaypoints() + { + // Arrange: components {0,1} and {2,3}, each with a parallel pair and a self-loop. + var nodes = new List { new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight), new(NodeWidth, NodeHeight) }; + var edges = new List { new(0, 1), new(0, 1), new(0, 0), new(2, 3), new(2, 3), new(3, 3) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act: must not throw despite parallel pairs and self-loops spanning two components. + ComponentPacker.WithDefaultStages().Apply(graph); + + // Assert: Waypoints are index-aligned with the acyclic edge set (same contract as single-component). + Assert.Equal(graph.Acyclic.Count, graph.Waypoints.Count); + + // Assert: self-loops dropped, parallel pairs collapsed — one acyclic edge per component. + Assert.DoesNotContain(graph.Acyclic, e => e.Source == e.Target); + Assert.Equal(2, graph.Acyclic.Count); + Assert.Contains(graph.Acyclic, e => e.Source == 0 && e.Target == 1); + Assert.Contains(graph.Acyclic, e => e.Source == 2 && e.Target == 3); + + // Assert: each edge's polyline endpoints lie on its own component's (offset) boxes. + var routed = BuildRouted(graph); + const double eps = 3.0; + AssertEndpointsOnBoxes(graph, routed[(0, 1)], 0, 1, eps); + AssertEndpointsOnBoxes(graph, routed[(2, 3)], 2, 3, eps); + } + + /// Builds a (source, target) to polyline lookup over a graph's acyclic edge set. + /// The laid-out graph. + /// A dictionary keyed by each acyclic edge's endpoints. + private static Dictionary<(int Source, int Target), IReadOnlyList> BuildRouted(LayeredGraph graph) + { + var routed = new Dictionary<(int Source, int Target), IReadOnlyList>(); + for (var k = 0; k < graph.Acyclic.Count; k++) + { + routed[(graph.Acyclic[k].Source, graph.Acyclic[k].Target)] = graph.Waypoints[k]; + } + + return routed; + } + + /// Asserts that a polyline's first/last waypoints lie on its source/target boxes. + /// The laid-out graph. + /// The routed polyline. + /// Source node index. + /// Target node index. + /// Tolerance in logical pixels. + private static void AssertEndpointsOnBoxes(LayeredGraph graph, IReadOnlyList waypoints, int source, int target, double eps) + { + var first = waypoints[0]; + var last = waypoints[^1]; + Assert.InRange(first.X, graph.AugX[source] - eps, graph.AugX[source] + graph.Nodes[source].Width + eps); + Assert.InRange(first.Y, graph.AugY[source] - eps, graph.AugY[source] + graph.Nodes[source].Height + eps); + Assert.InRange(last.X, graph.AugX[target] - eps, graph.AugX[target] + graph.Nodes[target].Width + eps); + Assert.InRange(last.Y, graph.AugY[target] - eps, graph.AugY[target] + graph.Nodes[target].Height + eps); + } + + /// Runs the default ELK-layered stage sequence directly on a fresh graph. + /// Input nodes. + /// Input edges. + /// The graph after the full default stage sequence. + private static LayeredGraph RunDefaultStages(List nodes, List edges) + { + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + new CrossingMinimizer().Apply(graph); + new BrandesKopfPlacer().Apply(graph); + new PortDistributor().Apply(graph); + new LayeredCorridorRouter().Apply(graph); + new LongEdgeJoiner().Apply(graph); + new AxisTransform().Apply(graph); + return graph; + } + + /// Returns a fresh copy of a node list. + /// Nodes to copy. + /// A new list with the same node values. + private static List CloneNodes(List nodes) => [.. nodes]; + + /// Returns a fresh copy of an edge list. + /// Edges to copy. + /// A new list with the same edge values. + private static List CloneEdges(List edges) => [.. edges]; + + /// Determines whether the boxes of two real nodes overlap. + /// The laid-out graph. + /// First node index. + /// Second node index. + /// when the two boxes intersect. + private static bool BoxesOverlap(LayeredGraph graph, int a, int b) + { + var ax = graph.AugX[a]; + var ay = graph.AugY[a]; + var bx = graph.AugX[b]; + var by = graph.AugY[b]; + var overlapX = ax < bx + graph.Nodes[b].Width && bx < ax + graph.Nodes[a].Width; + var overlapY = ay < by + graph.Nodes[b].Height && by < ay + graph.Nodes[a].Height; + return overlapX && overlapY; + } + + /// Asserts that a given waypoint of an edge lies on a node's box, within a tolerance. + /// The laid-out graph. + /// Edge index (original edge order). + /// Waypoint index; a negative value counts from the end. + /// The node whose box the waypoint should lie on. + /// Tolerance in logical pixels. + private static void AssertWaypointOnBox(LayeredGraph graph, int edge, int point, int node, double eps) + { + var waypoints = graph.Waypoints[edge]; + var p = point < 0 ? waypoints[waypoints.Count + point] : waypoints[point]; + var x = graph.AugX[node]; + var y = graph.AugY[node]; + Assert.InRange(p.X, x - eps, x + graph.Nodes[node].Width + eps); + Assert.InRange(p.Y, y - eps, y + graph.Nodes[node].Height + eps); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CrossingMinimizerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CrossingMinimizerTests.cs new file mode 100644 index 0000000..02c3af8 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CrossingMinimizerTests.cs @@ -0,0 +1,164 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering grouping of augmented nodes by layer and +/// that the grouping partitions every augmented node exactly once. +/// +public sealed class CrossingMinimizerTests +{ + /// + /// A diamond (0->1, 0->2, 1->3, 2->3) yields three layer groups holding the + /// source, the two branches, and the join, with per-layer counts 1, 2, 1. + /// + [Fact] + public void CrossingMinimizer_Apply_TwoLayerGraph_GroupsNodesByLayer() + { + // Arrange: a four-node diamond. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(0, 2), new(1, 3), new(2, 3) }; + var graph = BuildGroupedGraph(nodes, edges); + + // Assert: three layers with counts 1, 2, 1. + Assert.Equal(3, graph.Groups.Count); + Assert.Single(graph.Groups[0]); + Assert.Equal(2, graph.Groups[1].Count); + Assert.Single(graph.Groups[2]); + } + + /// + /// The groups partition every augmented node (including the dummy nodes of a long edge) + /// exactly once. + /// + [Fact] + public void CrossingMinimizer_Apply_AllAugmentedNodesAppearInGroups() + { + // Arrange: a chain plus a span-three edge (introduces two dummy nodes). + var nodes = new List { new(60, 40), new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(1, 2), new(2, 3), new(0, 3) }; + var graph = BuildGroupedGraph(nodes, edges); + + // Act: collect every index that appears across all layer groups. + var indices = graph.Groups.SelectMany(g => g).OrderBy(i => i).ToList(); + + // Assert: the groups contain exactly the indices 0..AugNodes.Count-1, each once. + Assert.Equal(graph.AugNodes.Count, indices.Count); + Assert.Equal(Enumerable.Range(0, graph.AugNodes.Count), indices); + } + + /// + /// A two-layer ordering whose natural (index) order crosses every edge is reordered by the + /// barycenter sweep so that the total number of edge crossings strictly decreases. + /// + [Fact] + public void CrossingMinimizer_Apply_CrossingProneOrdering_ReducesCrossings() + { + // Arrange: two layers wired in fully-reversed order (0->5, 1->4, 2->3) so that the + // natural index order crosses every pair of edges. + var nodes = new List + { + new(60, 40), new(60, 40), new(60, 40), new(60, 40), new(60, 40), new(60, 40), + }; + var edges = new List { new(0, 5), new(1, 4), new(2, 3) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + + // Baseline crossing count for the natural (index-order) layer grouping. + var initialGroups = GroupByLayerInIndexOrder(graph.AugNodes); + var before = CountCrossings(initialGroups, graph.AugEdges, graph.AugNodes); + + // Act: run the barycenter crossing-minimization sweep. + new CrossingMinimizer().Apply(graph); + + // Assert: the sweep strictly reduces the number of edge crossings. + var after = CountCrossings(graph.Groups, graph.AugEdges, graph.AugNodes); + Assert.True(before > 0, "the natural ordering should contain crossings to reduce"); + Assert.True(after < before, $"expected fewer crossings after ordering (before={before}, after={after})"); + } + + /// Groups augmented-node indices by layer, preserving the natural index order. + /// The augmented nodes. + /// One ordered index list per layer, in ascending node-index order. + private static List> GroupByLayerInIndexOrder(List augNodes) + { + var maxLayer = augNodes.Max(a => a.Layer); + var groups = new List>(); + for (var l = 0; l <= maxLayer; l++) + { + groups.Add([]); + } + + for (var i = 0; i < augNodes.Count; i++) + { + groups[augNodes[i].Layer].Add(i); + } + + return groups; + } + + /// + /// Counts the total number of edge crossings implied by the given per-layer orderings. + /// + /// The per-layer ordered index lists. + /// The augmented sub-edges (each spanning one layer). + /// The augmented nodes (for layer lookup). + /// The number of crossing edge pairs. + private static int CountCrossings(List> groups, List augEdges, List augNodes) + { + var pos = new Dictionary(); + foreach (var group in groups) + { + for (var i = 0; i < group.Count; i++) + { + pos[group[i]] = i; + } + } + + var crossings = 0; + for (var a = 0; a < augEdges.Count; a++) + { + for (var b = a + 1; b < augEdges.Count; b++) + { + var e1 = augEdges[a]; + var e2 = augEdges[b]; + + // Only edges leaving the same layer can cross in the corridor between two layers. + if (augNodes[e1.Source].Layer != augNodes[e2.Source].Layer) + { + continue; + } + + int u1 = pos[e1.Source], v1 = pos[e1.Target]; + int u2 = pos[e2.Source], v2 = pos[e2.Target]; + if ((u1 < u2 && v1 > v2) || (u1 > u2 && v1 < v2)) + { + crossings++; + } + } + } + + return crossings; + } + + /// Runs the stages up to and including crossing minimization and returns the graph. + /// Input nodes. + /// Input edges. + /// The graph after the crossing-minimization stage. + private static LayeredGraph BuildGroupedGraph(List nodes, List edges) + { + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + new CrossingMinimizer().Apply(graph); + return graph; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CycleBreakerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CycleBreakerTests.cs new file mode 100644 index 0000000..38570ff --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CycleBreakerTests.cs @@ -0,0 +1,100 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering production of an acyclic edge set from a +/// cyclic graph and removal of self-loops and duplicate edges. +/// +public sealed class CycleBreakerTests +{ + /// + /// A three-node cycle (0->1->2->0) is broken into an edge set with no directed + /// cycle, while keeping all three connections represented. + /// + [Fact] + public void CycleBreaker_Apply_GraphWithCycle_ProducesAcyclicEdgeSet() + { + // Arrange: a tight three-node cycle. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(1, 2), new(2, 0) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act: break cycles. + new CycleBreaker().Apply(graph); + + // Assert: all three connections survive and the result is acyclic. + Assert.Equal(3, graph.Acyclic.Count); + Assert.True(IsAcyclic(graph.N, graph.Acyclic), "Resulting edge set still contains a cycle."); + } + + /// + /// Self-loops are dropped and duplicate source-target pairs are collapsed, so the acyclic + /// set contains each distinct connection once and no self-loop. + /// + [Fact] + public void CycleBreaker_Apply_SelfLoopsAndDuplicates_AreRemoved() + { + // Arrange: a self-loop on node 0, a duplicated 0->1 edge, and a 1->2 edge. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 0), new(0, 1), new(0, 1), new(1, 2) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act: break cycles. + new CycleBreaker().Apply(graph); + + // Assert: only the two distinct, non-self connections remain. + Assert.Equal(2, graph.Acyclic.Count); + Assert.DoesNotContain(graph.Acyclic, e => e.Source == e.Target); + } + + /// Returns true when the edge set induces no directed cycle (Kahn's algorithm). + /// Number of nodes. + /// Directed edges to test. + /// true if the edges form a directed acyclic graph; otherwise false. + private static bool IsAcyclic(int n, IReadOnlyList edges) + { + var inDegree = new int[n]; + var outgoing = new List[n]; + for (var i = 0; i < n; i++) + { + outgoing[i] = []; + } + + foreach (var e in edges) + { + outgoing[e.Source].Add(e.Target); + inDegree[e.Target]++; + } + + var queue = new Queue(); + for (var i = 0; i < n; i++) + { + if (inDegree[i] == 0) + { + queue.Enqueue(i); + } + } + + var processed = 0; + while (queue.Count > 0) + { + var u = queue.Dequeue(); + processed++; + foreach (var v in outgoing[u]) + { + if (--inDegree[v] == 0) + { + queue.Enqueue(v); + } + } + } + + return processed == n; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayerAssignerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayerAssignerTests.cs new file mode 100644 index 0000000..11290d1 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayerAssignerTests.cs @@ -0,0 +1,60 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering monotonic layer assignment along a chain and +/// longest-path layer assignment for a diamond graph. +/// +public sealed class LayerAssignerTests +{ + /// + /// A linear chain (0->1->2) receives strictly increasing, contiguous layer + /// indices 0, 1, 2. + /// + [Fact] + public void LayerAssigner_Apply_LinearChain_AssignsMonotonicLayers() + { + // Arrange: a three-node chain. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(1, 2) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act: break cycles, then assign layers. + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + + // Assert: contiguous, strictly increasing layers along the chain. + Assert.Equal(0, graph.NodeLayers[0]); + Assert.Equal(1, graph.NodeLayers[1]); + Assert.Equal(2, graph.NodeLayers[2]); + } + + /// + /// A diamond (0->1, 0->2, 1->3, 2->3) places the source at layer 0, both + /// branches at layer 1, and the join at the longest-path layer 2. + /// + [Fact] + public void LayerAssigner_Apply_DiamondGraph_AssignsLongestPathLayers() + { + // Arrange: a four-node diamond. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(0, 2), new(1, 3), new(2, 3) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act: break cycles, then assign layers. + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + + // Assert: source at 0, branches at 1, join at the longest-path layer 2. + Assert.Equal(0, graph.NodeLayers[0]); + Assert.Equal(1, graph.NodeLayers[1]); + Assert.Equal(1, graph.NodeLayers[2]); + Assert.Equal(2, graph.NodeLayers[3]); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredGraphTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredGraphTests.cs new file mode 100644 index 0000000..060598a --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredGraphTests.cs @@ -0,0 +1,64 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering null-argument validation and that the +/// supplied nodes, edges, direction, and node count are preserved. +/// +public sealed class LayeredGraphTests +{ + /// + /// Constructing a graph with a null node list throws . + /// + [Fact] + public void LayeredGraph_Constructor_NullNodes_ThrowsArgumentNullException() + { + // Arrange: a valid edge list but a null node list. + var edges = new List(); + + // Act / Assert: construction rejects the null node list. + Assert.Throws( + () => new LayeredGraph(null!, edges, LayoutDirection.Right)); + } + + /// + /// Constructing a graph with a null edge list throws . + /// + [Fact] + public void LayeredGraph_Constructor_NullEdges_ThrowsArgumentNullException() + { + // Arrange: a valid node list but a null edge list. + var nodes = new List { new(60, 40) }; + + // Act / Assert: construction rejects the null edge list. + Assert.Throws( + () => new LayeredGraph(nodes, null!, LayoutDirection.Right)); + } + + /// + /// Construction preserves the supplied nodes, edges, and direction and reports the + /// input node count. + /// + [Fact] + public void LayeredGraph_Constructor_ValidInput_StoresNodesEdgesDirectionAndCount() + { + // Arrange: three nodes and two edges. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(1, 2) }; + + // Act: construct the shared graph state. + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Assert: the inputs are preserved unchanged. + Assert.Same(nodes, graph.Nodes); + Assert.Same(edges, graph.Edges); + Assert.Equal(LayoutDirection.Right, graph.Direction); + Assert.Equal(3, graph.N); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredLayoutPipelineTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredLayoutPipelineTests.cs new file mode 100644 index 0000000..6c95b32 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredLayoutPipelineTests.cs @@ -0,0 +1,81 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering default-stage assembly and +/// execution, rejection of recursive hierarchy handling, and null-argument validation. +/// +public sealed class LayeredLayoutPipelineTests +{ + /// + /// The default pipeline runs over a three-node chain without throwing and produces one + /// connector waypoint list per input edge. + /// + [Fact] + public void LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing() + { + // Arrange: a 0->1->2 chain and a default pipeline. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(1, 2) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + var pipeline = LayeredLayoutPipeline.Builder() + .Direction(LayoutDirection.Right) + .Hierarchy(global::DemaConsulting.Rendering.Layout.Engine.Layered.HierarchyHandling.Flat) + .AddDefaultStages() + .Build(); + + // Act: run the full pipeline. + pipeline.Run(graph); + + // Assert: one waypoint list was produced per edge. + Assert.Equal(edges.Count, graph.Waypoints.Count); + } + + /// + /// Building a pipeline with recursive hierarchy handling throws + /// because the mode is not yet implemented. + /// + [Fact] + public void LayeredLayoutPipeline_Build_RecursiveHierarchy_ThrowsNotSupportedException() + { + // Arrange: a builder configured for recursive hierarchy handling. + var builder = LayeredLayoutPipeline.Builder() + .Hierarchy(global::DemaConsulting.Rendering.Layout.Engine.Layered.HierarchyHandling.Recursive) + .AddDefaultStages(); + + // Act / Assert: building rejects the unsupported mode. + Assert.Throws(() => builder.Build()); + } + + /// + /// Adding a null stage to the builder throws . + /// + [Fact] + public void LayeredLayoutPipeline_AddStage_NullStage_ThrowsArgumentNullException() + { + // Arrange: a fresh builder. + var builder = LayeredLayoutPipeline.Builder(); + + // Act / Assert: a null stage is rejected. + Assert.Throws(() => builder.AddStage(null!)); + } + + /// + /// Running a pipeline with a null graph throws . + /// + [Fact] + public void LayeredLayoutPipeline_Run_NullGraph_ThrowsArgumentNullException() + { + // Arrange: a built default pipeline. + var pipeline = LayeredLayoutPipeline.Builder().AddDefaultStages().Build(); + + // Act / Assert: running with a null graph is rejected. + Assert.Throws(() => pipeline.Run(null!)); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredPipelineEquivalenceTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredPipelineEquivalenceTests.cs new file mode 100644 index 0000000..b9307b6 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredPipelineEquivalenceTests.cs @@ -0,0 +1,238 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Behavior-preservation gate for the layered-pipeline refactor. Feeds many graphs through +/// both the legacy monolithic engine () +/// 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. +/// +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. + /// + [Fact] + public void Pipeline_MatchesLegacyOracle_OnRandomGraphs() + { + for (var seed = 0; seed < 2000; seed++) + { + var (nodes, edges) = BuildRandomGraph(seed); + AssertEquivalent($"random seed {seed}", nodes, edges); + } + } + + /// An empty graph produces identical (degenerate) results from both engines. + [Fact] + public void Pipeline_MatchesLegacyOracle_OnEmptyGraph() + { + AssertEquivalent("empty", [], []); + } + + /// + /// A drone-interconnect-style graph (seven heterogeneously sized parts with a mix of + /// short and long edges) produces identical results from both engines. + /// + [Fact] + public void Pipeline_MatchesLegacyOracle_OnDroneLikeGraph() + { + var nodes = new List + { + new(150, 54), // airframe + new(150, 54), // battery + new(230, 94), // controller + new(150, 54), // gps + new(150, 54), // imu + new(153, 54), // motors + new(209, 54), // propellers + }; + var edges = new List + { + new(2, 0), + new(2, 1), + new(2, 3), + new(2, 4), + new(2, 5), + new(5, 6), + new(0, 6), + }; + + AssertEquivalent("drone-like", nodes, edges); + } + + /// + /// A larger workstation-interconnect-style graph (twelve parts, multiple layers, a long + /// edge spanning three layers, and a back edge) produces identical results. + /// + [Fact] + public void Pipeline_MatchesLegacyOracle_OnWorkstationLikeGraph() + { + var nodes = new List(); + for (var i = 0; i < 12; i++) + { + nodes.Add(new LayerNode(120 + (i % 4 * 30), 50 + (i % 3 * 20))); + } + + var edges = new List + { + new(0, 1), + new(0, 2), + new(1, 3), + new(2, 3), + new(3, 4), + new(3, 5), + new(4, 6), + new(5, 7), + new(6, 8), + new(7, 8), + new(0, 8), // long edge spanning several layers + new(8, 0), // back edge (cycle) + new(9, 3), + new(10, 4), + new(11, 5), + }; + + AssertEquivalent("workstation-like", nodes, edges); + } + + /// + /// Canonical named topologies (chain, diamond, long edge, self loop, parallel edges, + /// disconnected components, and a tight cycle) each produce identical results. + /// + /// A human-readable name for the topology, used in failure messages. + [Theory] + [InlineData("chain")] + [InlineData("diamond")] + [InlineData("longedge")] + [InlineData("selfloop")] + [InlineData("parallel")] + [InlineData("disconnected")] + [InlineData("cycle")] + public void Pipeline_MatchesLegacyOracle_OnNamedTopologies(string name) + { + var (nodes, edges) = name switch + { + "chain" => (Sizes(3), Edges((0, 1), (1, 2))), + "diamond" => (Sizes(4), Edges((0, 1), (0, 2), (1, 3), (2, 3))), + "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()), + }; + + AssertEquivalent(name, nodes, edges); + } + + /// Builds a list of uniformly sized nodes. + private static List Sizes(int count) + { + var nodes = new List(count); + for (var i = 0; i < count; i++) + { + nodes.Add(new LayerNode(60, 40)); + } + + return nodes; + } + + /// Builds an edge list from source/target index pairs. + private static List Edges(params (int Source, int Target)[] pairs) + { + var edges = new List(pairs.Length); + foreach (var (s, t) in pairs) + { + edges.Add(new LayerEdge(s, t)); + } + + return edges; + } + + /// + /// Deterministically builds a pseudo-random graph for the given seed, exercising varied + /// node counts and sizes plus arbitrary edges (including self loops, parallel edges, + /// cycles, and multi-layer spans). + /// + private static (List Nodes, List Edges) BuildRandomGraph(int seed) + { + var rng = new Random(seed); + var n = rng.Next(0, 16); + + var nodes = new List(n); + for (var i = 0; i < n; i++) + { + nodes.Add(new LayerNode(rng.Next(40, 240), rng.Next(30, 120))); + } + + var edges = new List(); + if (n > 0) + { + var m = rng.Next(0, (n * 2) + 1); + for (var e = 0; e < m; e++) + { + edges.Add(new LayerEdge(rng.Next(0, n), rng.Next(0, n))); + } + } + + return (nodes, edges); + } + + /// + /// Runs both engines on the same input and asserts bit-for-bit equality of every field + /// of the resulting LayerResult. + /// + private static void AssertEquivalent(string context, List nodes, List edges) + { + var expected = LegacyInterconnectionLayoutEngineOracle.Place(nodes, edges); + var actual = InterconnectionLayoutEngine.Place(nodes, edges); + + AssertExact($"{context}: TotalWidth", expected.TotalWidth, actual.TotalWidth); + AssertExact($"{context}: TotalHeight", expected.TotalHeight, actual.TotalHeight); + + Assert.Equal(expected.Rects.Count, actual.Rects.Count); + for (var i = 0; i < expected.Rects.Count; i++) + { + AssertExact($"{context}: Rects[{i}].X", expected.Rects[i].X, actual.Rects[i].X); + AssertExact($"{context}: Rects[{i}].Y", expected.Rects[i].Y, actual.Rects[i].Y); + AssertExact($"{context}: Rects[{i}].Width", expected.Rects[i].Width, actual.Rects[i].Width); + AssertExact($"{context}: Rects[{i}].Height", expected.Rects[i].Height, actual.Rects[i].Height); + } + + Assert.Equal(expected.NodeLayers.Count, actual.NodeLayers.Count); + for (var i = 0; i < expected.NodeLayers.Count; i++) + { + Assert.Equal(expected.NodeLayers[i], actual.NodeLayers[i]); + } + + Assert.Equal(expected.ConnectorWaypoints.Count, actual.ConnectorWaypoints.Count); + for (var e = 0; e < expected.ConnectorWaypoints.Count; e++) + { + var ew = expected.ConnectorWaypoints[e]; + var aw = actual.ConnectorWaypoints[e]; + Assert.Equal(ew.Count, aw.Count); + for (var w = 0; w < ew.Count; w++) + { + AssertExact($"{context}: Waypoint[{e}][{w}].X", ew[w].X, aw[w].X); + AssertExact($"{context}: Waypoint[{e}][{w}].Y", ew[w].Y, aw[w].Y); + } + } + } + + /// Asserts that two doubles are identical at the bit level (no tolerance). + private static void AssertExact(string context, double expected, double actual) + { + Assert.True( + BitConverter.DoubleToInt64Bits(expected) == BitConverter.DoubleToInt64Bits(actual), + $"{context}: expected {expected:R} but got {actual:R}"); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LegacyInterconnectionLayoutEngineOracle.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LegacyInterconnectionLayoutEngineOracle.cs new file mode 100644 index 0000000..e73eb81 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LegacyInterconnectionLayoutEngineOracle.cs @@ -0,0 +1,1617 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Frozen, byte-for-byte copy of the pre-refactor monolithic interconnection layout engine. +/// Retained only as the behavior oracle for the layered-pipeline equivalence tests; the product +/// engine is now a thin façade over the pipeline, and +/// this copy proves the refactor preserves its output exactly. +/// +internal static class LegacyInterconnectionLayoutEngineOracle +{ + /// Vertical gap between adjacent nodes stacked within the same layer. + private const double NodeSpacing = 30.0; + + /// Minimum corridor width (node-node spacing) between adjacent columns. + private const double CorridorMinWidth = 70.0; + + /// Slot-to-slot spacing within a corridor (ELK edgeEdgeSpacing). + private const double EdgeSpacing = 16.0; + + /// Clearance from corridor edge to the nearest routing slot (ELK edgeNodeSpacing). + private const double ConnectorClearance = 10.0; + + /// Uniform padding added around the placed content. + private const double Padding = 20.0; + + /// Number of Barycenter ordering sweeps (down + up = one round). + private const int BarycentricSweeps = 4; + + /// Tolerance for treating a segment as straight (no bend points needed). + private const double StraightTolerance = 1e-6; + + // ── Augmented-graph types ──────────────────────────────────────────────── + + /// A node in the augmented Sugiyama graph (real part box or long-edge dummy). + private sealed record AugNode(double Width, double Height, int Layer, bool IsDummy = false); + + /// A sub-edge in the augmented graph after long-edge splitting. + private readonly record struct AugEdge(int Source, int Target, int OrigEdgeIndex); + + // ── Routing types ──────────────────────────────────────────────────────── + + /// + /// A routing segment: one sub-edge crossing a single corridor, carrying its ELK port + /// coordinates and the slot assigned by topological numbering. + /// + private sealed class Segment + { + public int AugEdgeIndex; + + /// Y coordinate of the EAST (source-layer) port (ELK incomingConnectionCoordinates). + public double SourceY; + + /// Y coordinate of the WEST (target-layer) port (ELK outgoingConnectionCoordinates). + public double TargetY; + + /// Min of SourceY and TargetY, used for ELK crossing-count computation. + public double Lo; + + /// Max of SourceY and TargetY, used for ELK crossing-count computation. + public double Hi; + + /// Assigned routing slot (column offset within corridor). Set by topological numbering. + public int RoutingSlot; + + /// Remaining in-degree during topological BFS; initialised from Incoming.Count. + public int InWeight; + + + /// Dependencies for which this segment must precede the target. + public List Outgoing { get; } = []; + + /// Dependencies for which a predecessor must precede this segment. + public List Incoming { get; } = []; + } + + /// + /// A directed dependency between two routing segments: the source segment prefers + /// to occupy a lower slot than the target segment (ELK HyperEdgeSegmentDependency). + /// + private sealed class SegDep + { + public Segment Source { get; } + public Segment Target { get; } + public int Weight { get; } + + public SegDep(Segment src, Segment tgt, int weight) + { + Source = src; + Target = tgt; + Weight = weight; + src.Outgoing.Add(this); + tgt.Incoming.Add(this); + } + + /// Removes this dependency from both endpoints' adjacency lists. + public void Remove() + { + Source.Outgoing.Remove(this); + Target.Incoming.Remove(this); + } + } + + // ── Public entry point ─────────────────────────────────────────────────── + + /// + /// Computes a full Sugiyama layered placement and ELK-style slot routing for the given nodes + /// and directed edges, returning box positions and orthogonal connector waypoints. + /// + /// Input nodes to place, in caller order. + /// Directed edges between nodes (by index). + /// Placement result with rects, layer assignments, and connector waypoints. + public static LayerResult Place( + IReadOnlyList nodes, + IReadOnlyList edges) + { + ArgumentNullException.ThrowIfNull(nodes); + ArgumentNullException.ThrowIfNull(edges); + + var n = nodes.Count; + if (n == 0) + { + return new LayerResult([], 2.0 * Padding, 2.0 * Padding, [], []); + } + + // Phase 1: make the graph acyclic and assign longest-path layers. + var acyclic = BreakCycles(n, edges); + var nodeLayers = AssignLayers(n, acyclic); + + // Phase 1.5: insert one dummy node per intermediate layer on each long edge. + var (augNodes, augEdges) = InsertLongEdgeDummies(n, nodes, nodeLayers, acyclic); + + // Phase 2: Barycenter ordering on the augmented graph. + var groups = GroupByLayerAug(augNodes); + OrderLayersAug(groups, augNodes.Count, augEdges); + + // Phase 3: coordinate assignment (stacking + corridor widths). + var (augX, augY, columnX, maxColWidth) = AssignCoordinatesAug(augNodes, groups, augEdges); + + // Phase 4: port distribution + ELK slot routing. + var waypoints = BuildRoutesAug(n, nodes, augNodes, augEdges, acyclic, augX, augY, columnX, maxColWidth); + + // Assemble result. + var rects = new Rect[n]; + for (var i = 0; i < n; i++) + { + rects[i] = new Rect(augX[i], augY[i], nodes[i].Width, nodes[i].Height); + } + + var lastLayer = columnX.Length - 1; + var totalWidth = columnX[lastLayer] + maxColWidth[lastLayer] + Padding; + var totalHeight = Padding; + for (var i = 0; i < n; i++) + { + totalHeight = Math.Max(totalHeight, augY[i] + nodes[i].Height + Padding); + } + + return new LayerResult(rects, totalWidth, totalHeight, nodeLayers, waypoints); + } + + // ── Phase 1: Cycle breaking ────────────────────────────────────────────── + + /// + /// Returns the edge set with cycle-causing back edges reversed, using DFS to classify any + /// edge to a node still on the recursion stack as a back edge. + /// + private static List BreakCycles(int n, IReadOnlyList edges) + { + var adjacency = new List[n]; + for (var i = 0; i < n; i++) + { + adjacency[i] = []; + } + + foreach (var e in edges) + { + if (e.Source != e.Target) + { + adjacency[e.Source].Add(e.Target); + } + } + + var visited = new bool[n]; + var onStack = new bool[n]; + var backEdges = new HashSet<(int, int)>(); + + void Dfs(int u) + { + visited[u] = true; + onStack[u] = true; + foreach (var v in adjacency[u]) + { + if (onStack[v]) + { + backEdges.Add((u, v)); + } + else if (!visited[v]) + { + Dfs(v); + } + } + + // S4143: standard DFS coloring — onStack[u] is read by recursive calls between the + // true/false assignments; the analyzer cannot see across the recursion. +#pragma warning disable S4143 + onStack[u] = false; +#pragma warning restore S4143 + } + + for (var i = 0; i < n; i++) + { + if (!visited[i]) + { + Dfs(i); + } + } + + var result = new List(); + var seen = new HashSet<(int, int)>(); + foreach (var e in edges) + { + if (e.Source == e.Target) + { + continue; + } + + var (from, to) = backEdges.Contains((e.Source, e.Target)) + ? (e.Target, e.Source) + : (e.Source, e.Target); + + if (from != to && seen.Add((from, to))) + { + result.Add(new LayerEdge(from, to)); + } + } + + return result; + } + + // ── Phase 1: Longest-path layer assignment ─────────────────────────────── + + /// + /// Assigns each node to a layer equal to the length of its longest incoming path + /// (sources at layer 0, sinks at the maximum layer). + /// + private static int[] AssignLayers(int n, List edges) + { + var outgoing = new List[n]; + var inDegree = new int[n]; + for (var i = 0; i < n; i++) + { + outgoing[i] = []; + } + + foreach (var e in edges) + { + outgoing[e.Source].Add(e.Target); + inDegree[e.Target]++; + } + + var layer = new int[n]; + var queue = new Queue(); + for (var i = 0; i < n; i++) + { + if (inDegree[i] == 0) + { + queue.Enqueue(i); + } + } + + var remaining = (int[])inDegree.Clone(); + while (queue.Count > 0) + { + var u = queue.Dequeue(); + foreach (var v in outgoing[u]) + { + layer[v] = Math.Max(layer[v], layer[u] + 1); + if (--remaining[v] == 0) + { + queue.Enqueue(v); + } + } + } + + return layer; + } + + // ── Phase 1.5: Long-edge dummy insertion (ELK LongEdgeSplitter) ────────── + + /// + /// Splits every edge spanning more than one layer into a chain of unit-span sub-edges by + /// inserting one zero-size dummy node at each intermediate layer, following + /// ELK's LongEdgeSplitter phase. + /// + private static (List AugNodes, List AugEdges) InsertLongEdgeDummies( + int n, + IReadOnlyList nodes, + int[] nodeLayers, + List acyclic) + { + var augNodes = new List(n + acyclic.Count); + for (var i = 0; i < n; i++) + { + augNodes.Add(new AugNode(nodes[i].Width, nodes[i].Height, nodeLayers[i])); + } + + var augEdges = new List(acyclic.Count * 2); + for (var e = 0; e < acyclic.Count; e++) + { + var edge = acyclic[e]; + var span = nodeLayers[edge.Target] - nodeLayers[edge.Source]; + + if (span <= 0) + { + continue; + } + + if (span == 1) + { + augEdges.Add(new AugEdge(edge.Source, edge.Target, e)); + } + else + { + // Chain: src → d1 → d2 → … → tgt with one dummy per intermediate layer. + var prev = edge.Source; + for (var l = nodeLayers[edge.Source] + 1; l < nodeLayers[edge.Target]; l++) + { + var dIdx = augNodes.Count; + augNodes.Add(new AugNode(0.0, 0.0, l, IsDummy: true)); + augEdges.Add(new AugEdge(prev, dIdx, e)); + prev = dIdx; + } + + augEdges.Add(new AugEdge(prev, edge.Target, e)); + } + } + + return (augNodes, augEdges); + } + + // ── Phase 2: Barycenter ordering on augmented graph ────────────────────── + + /// Groups augmented-node indices by layer. + private static List> GroupByLayerAug(List augNodes) + { + var maxLayer = augNodes.Max(a => a.Layer); + var groups = new List>(maxLayer + 1); + for (var l = 0; l <= maxLayer; l++) + { + groups.Add([]); + } + + for (var i = 0; i < augNodes.Count; i++) + { + groups[augNodes[i].Layer].Add(i); + } + + return groups; + } + + /// + /// Runs Barycenter sweeps over the augmented graph + /// (real nodes and dummies) to reduce edge crossings. + /// + private static void OrderLayersAug(List> groups, int numAug, List augEdges) + { + var leftNeighbors = new List[numAug]; + var rightNeighbors = new List[numAug]; + for (var i = 0; i < numAug; i++) + { + leftNeighbors[i] = []; + rightNeighbors[i] = []; + } + + foreach (var ae in augEdges) + { + rightNeighbors[ae.Source].Add(ae.Target); + leftNeighbors[ae.Target].Add(ae.Source); + } + + for (var sweep = 0; sweep < BarycentricSweeps; sweep++) + { + if (sweep % 2 == 0) + { + for (var l = 1; l < groups.Count; l++) + { + SortByBarycenter(groups[l], groups[l - 1], leftNeighbors); + } + } + else + { + for (var l = groups.Count - 2; l >= 0; l--) + { + SortByBarycenter(groups[l], groups[l + 1], rightNeighbors); + } + } + } + } + + /// + /// Sorts by the average position of each node's neighbors in + /// ; nodes without neighbors keep their current relative order. + /// + private static void SortByBarycenter(List layer, List adjacentLayer, List[] neighbors) + { + var position = new Dictionary(); + for (var i = 0; i < adjacentLayer.Count; i++) + { + position[adjacentLayer[i]] = i; + } + + var keyed = new List<(int Node, double Key, int Original)>(layer.Count); + for (var i = 0; i < layer.Count; i++) + { + var node = layer[i]; + var ns = neighbors[node].Where(position.ContainsKey).ToList(); + var key = ns.Count > 0 ? ns.Average(x => position[x]) : i; + keyed.Add((node, key, i)); + } + + keyed.Sort((a, b) => + { + var c = a.Key.CompareTo(b.Key); + return c != 0 ? c : a.Original.CompareTo(b.Original); + }); + + for (var i = 0; i < layer.Count; i++) + { + layer[i] = keyed[i].Node; + } + } + + // ── Phase 3: Coordinate assignment (BK Y-placement + ELK placeNodesHorizontally) ────── + + /// + /// Assigns absolute X and Y coordinates to all augmented nodes. + /// + /// + /// + /// Y assignment follows the Brandes-Köpf (BK) four-layout balanced algorithm + /// (BKNodePlacer + BKAligner + BKCompactor): four independent + /// vertical alignments (DOWN/UP × RIGHT/LEFT) are compacted and their per-node medians + /// averaged to produce port-aligned, crossing-minimized vertical positions. + /// + /// + /// X assignment follows ELK's LGraphUtil.placeNodesHorizontally: dummies are placed at + /// the horizontal center of their column; real nodes are left-aligned to their column start. + /// Corridor widths are derived from sub-edge counts per corridor. + /// + /// + private static (double[] AugX, double[] AugY, double[] ColumnX, double[] MaxColWidth) AssignCoordinatesAug( + List augNodes, + List> groups, + List augEdges) + { + var layerCount = groups.Count; + var numAug = augNodes.Count; + + // Maximum real-node width per layer (dummies have width 0). + var maxColWidth = new double[layerCount]; + for (var i = 0; i < numAug; i++) + { + if (!augNodes[i].IsDummy) + { + maxColWidth[augNodes[i].Layer] = Math.Max(maxColWidth[augNodes[i].Layer], augNodes[i].Width); + } + } + + // Sub-edges per corridor: one sub-edge per augEdge, keyed on source layer. + var corridorEdgeCounts = new int[Math.Max(1, layerCount - 1)]; + foreach (var ae in augEdges) + { + var l = augNodes[ae.Source].Layer; + if (l >= 0 && l < corridorEdgeCounts.Length) + { + corridorEdgeCounts[l]++; + } + } + + // Column X positions. Corridor width: ELK routingWidth = 2*edgeNodeSpacing + (n-1)*edgeEdgeSpacing. + var columnX = new double[layerCount]; + columnX[0] = Padding; + for (var l = 1; l < layerCount; l++) + { + var cnt = corridorEdgeCounts[l - 1]; + var corridorWidth = cnt > 0 + ? Math.Max(CorridorMinWidth, (2.0 * ConnectorClearance) + ((cnt - 1) * EdgeSpacing)) + : CorridorMinWidth; + columnX[l] = columnX[l - 1] + maxColWidth[l - 1] + corridorWidth; + } + + // Assign X coordinates: dummies are centered in their column; real nodes left-align. + var augX = new double[numAug]; + for (var l = 0; l < layerCount; l++) + { + var colCenterX = columnX[l] + (maxColWidth[l] / 2.0); + foreach (var ni in groups[l]) + { + augX[ni] = augNodes[ni].IsDummy ? colCenterX : columnX[l]; + } + } + + // Assign Y coordinates using the Brandes-Köpf balanced four-layout algorithm. + var augY = BkAssignYCoordinates(augNodes, groups, augEdges); + + return (augX, augY, columnX, maxColWidth); + } + + // ── Phase 3 helpers: Brandes-Köpf Y-coordinate assignment ──────────────── + + /// + /// Assigns Y coordinates to all augmented nodes using the four-layout Brandes-Köpf + /// balanced algorithm, producing port-aligned vertical positions. + /// + /// + /// + /// Runs four independent (vDown × hRight) alignment-and-compaction pipelines, one for + /// each combination of vertical scan direction (DOWN = top-to-bottom, UP = bottom-to-top) + /// and horizontal scan direction (RIGHT = layer 0 → max, LEFT = layer max → 0). The + /// per-node average of the two middle values of the four results gives the final balanced + /// position. Padding is added once at the end. + /// + /// + /// Corresponds to ELK's BKNodePlacer orchestrating BKAligner and BKCompactor. + /// + /// + private static double[] BkAssignYCoordinates( + List augNodes, + List> groups, + List augEdges) + { + var numAug = augNodes.Count; + + // Step 0: precompute port positions, layer positions, and neighbor-edge lists. + BkPreprocess( + augNodes, groups, augEdges, + out var posInLayer, + out var srcRelPortY, + out var tgtRelPortY, + out var leftNeighborEdges, + out var rightNeighborEdges); + + // Step 1: mark type-1 conflicts (non-inner segments crossing inner segments). + var markedEdges = BkMarkConflicts(augNodes, groups, augEdges, posInLayer, leftNeighborEdges); + + // Steps 2–4: compute four independent (vDown × hRight) layouts. + var layouts = new double[4][]; + for (var d = 0; d < 4; d++) + { + // d=0: DOWN+RIGHT, d=1: UP+RIGHT, d=2: DOWN+LEFT, d=3: UP+LEFT. + var vDown = d % 2 == 0; + var hRight = d < 2; + + // Step 2: vertical alignment — builds block chains along the scan direction. + BkVerticalAlignment( + augNodes, groups, augEdges, posInLayer, + leftNeighborEdges, rightNeighborEdges, + markedEdges, vDown, hRight, + out var root, out var align); + + // Step 3: inside-block shift — adjusts nodes within each block to align ports. + var innerShift = BkInsideBlockShift( + augNodes, augEdges, root, align, + srcRelPortY, tgtRelPortY, hRight, + rightNeighborEdges, leftNeighborEdges); + + // Step 4: horizontal compaction — assigns absolute Y to each block root. + var blockY = BkHorizontalCompaction(augNodes, groups, root, align, innerShift, posInLayer, vDown); + + // Compute absolute Y for every node in this layout. + var y = new double[numAug]; + for (var i = 0; i < numAug; i++) + { + y[i] = blockY[root[i]] + innerShift[i]; + } + + layouts[d] = y; + } + + // Step 5: normalize each layout and return the balanced (median average) result. + return BkBalancedLayout(layouts, numAug); + } + + /// + /// Precomputes the lookup tables required by all four Brandes-Köpf layout passes: + /// per-node layer position, relative port Y offsets, and sorted neighbor-edge lists. + /// + /// + /// + /// Port positions follow ELK's BKAligner preprocessing convention: a dummy node + /// contributes relative port Y = 0 (the wire passes straight through); a real node + /// distributes its ports evenly between insets, or + /// at the midpoint when it has only one port on that face. + /// + /// + /// [v] lists augmented-edge indices whose target is v, + /// sorted ascending by the source node's position within its layer — used for RIGHT-direction + /// alignment. [v] lists augmented-edge indices whose + /// source is v, sorted ascending by the target node's position — used for LEFT-direction + /// alignment. Both lists are stable-sorted by edge index as a tiebreaker. + /// + /// + private static void BkPreprocess( + List augNodes, + List> groups, + List augEdges, + out int[] posInLayer, + out double[] srcRelPortY, + out double[] tgtRelPortY, + out List[] leftNeighborEdges, + out List[] rightNeighborEdges) + { + var numAug = augNodes.Count; + var numEdges = augEdges.Count; + + // Position of each node within its layer group (0-based index in groups[layer]). + posInLayer = new int[numAug]; + for (var l = 0; l < groups.Count; l++) + { + for (var k = 0; k < groups[l].Count; k++) + { + posInLayer[groups[l][k]] = k; + } + } + + // Collect outgoing/incoming edge indices per node for port computation. + // Capture posInLayer in a local so it can be used inside lambda expressions + // (C# prohibits capturing out parameters directly in lambdas — CS1628). + var posLayer = posInLayer; + var outEdges = new List[numAug]; + var inEdges = new List[numAug]; + for (var i = 0; i < numAug; i++) + { + outEdges[i] = []; + inEdges[i] = []; + } + + for (var ei = 0; ei < numEdges; ei++) + { + outEdges[augEdges[ei].Source].Add(ei); + inEdges[augEdges[ei].Target].Add(ei); + } + + // srcRelPortY[e]: Y of the source (EAST) port relative to source node's top-left. + // Dummy nodes pass the wire through at Y = 0 relative to their own position. + srcRelPortY = new double[numEdges]; + for (var ni = 0; ni < numAug; ni++) + { + var edges = outEdges[ni]; + if (edges.Count == 0) + { + continue; + } + + if (augNodes[ni].IsDummy) + { + foreach (var ei in edges) + { + srcRelPortY[ei] = 0.0; + } + } + else + { + // Sort by target's position in its layer, then edge index for stability. + var sorted = edges + .OrderBy(ei => posLayer[augEdges[ei].Target]) + .ThenBy(ei => ei) + .ToList(); + var portCount = sorted.Count; + for (var k = 0; k < portCount; k++) + { + srcRelPortY[sorted[k]] = portCount == 1 + ? augNodes[ni].Height / 2.0 + : ConnectorClearance + (k * (augNodes[ni].Height - (2.0 * ConnectorClearance)) / (portCount - 1)); + } + } + } + + // tgtRelPortY[e]: Y of the target (WEST) port relative to target node's top-left. + tgtRelPortY = new double[numEdges]; + for (var ni = 0; ni < numAug; ni++) + { + var edges = inEdges[ni]; + if (edges.Count == 0) + { + continue; + } + + if (augNodes[ni].IsDummy) + { + foreach (var ei in edges) + { + tgtRelPortY[ei] = 0.0; + } + } + else + { + // Sort by source's position in its layer, then edge index for stability. + var sorted = edges + .OrderBy(ei => posLayer[augEdges[ei].Source]) + .ThenBy(ei => ei) + .ToList(); + var portCount = sorted.Count; + for (var k = 0; k < portCount; k++) + { + tgtRelPortY[sorted[k]] = portCount == 1 + ? augNodes[ni].Height / 2.0 + : ConnectorClearance + (k * (augNodes[ni].Height - (2.0 * ConnectorClearance)) / (portCount - 1)); + } + } + } + + // leftNeighborEdges[v]: edges whose Target == v, sorted by source posInLayer. + leftNeighborEdges = new List[numAug]; + for (var i = 0; i < numAug; i++) + { + leftNeighborEdges[i] = []; + } + + for (var ei = 0; ei < numEdges; ei++) + { + leftNeighborEdges[augEdges[ei].Target].Add(ei); + } + + for (var i = 0; i < numAug; i++) + { + leftNeighborEdges[i].Sort((a, b) => + { + var c = posLayer[augEdges[a].Source].CompareTo(posLayer[augEdges[b].Source]); + return c != 0 ? c : a.CompareTo(b); + }); + } + + // rightNeighborEdges[v]: edges whose Source == v, sorted by target posInLayer. + rightNeighborEdges = new List[numAug]; + for (var i = 0; i < numAug; i++) + { + rightNeighborEdges[i] = []; + } + + for (var ei = 0; ei < numEdges; ei++) + { + rightNeighborEdges[augEdges[ei].Source].Add(ei); + } + + for (var i = 0; i < numAug; i++) + { + rightNeighborEdges[i].Sort((a, b) => + { + var c = posLayer[augEdges[a].Target].CompareTo(posLayer[augEdges[b].Target]); + return c != 0 ? c : a.CompareTo(b); + }); + } + } + + /// + /// Returns true when is incident to an inner segment — a sub-edge + /// where both endpoints are dummy nodes, forming part of a long-edge chain. + /// + /// + /// An inner segment has both endpoints as dummy nodes. The check inspects v's + /// lowest-positioned left neighbor (leftNeighborEdges[v][0]). Used by type-1 conflict + /// detection to identify nodes that anchor inner segments across layer boundaries. + /// + private static bool IsIncidentToInnerSegment( + int v, + List augNodes, + List augEdges, + List[] leftNeighborEdges) + => augNodes[v].IsDummy + && leftNeighborEdges[v].Count > 0 + && augNodes[augEdges[leftNeighborEdges[v][0]].Source].IsDummy; + + /// + /// Marks augmented edges that participate in type-1 conflicts: a non-inner segment + /// (at least one real-node endpoint) that crosses an inner segment (both endpoints + /// are dummy nodes from a long-edge chain). + /// + /// + /// Implements ELK's markConflicts procedure from BKNodePlacer. For each pair of + /// adjacent middle layers (i, i+1), the algorithm tracks the permitted source-position + /// range [k0, k1] established by each inner segment and marks any non-inner segment + /// whose source falls outside that range. Marked edges are excluded from vertical + /// alignment to preserve the topology of inner segments. + /// + private static HashSet BkMarkConflicts( + List augNodes, + List> groups, + List augEdges, + int[] posInLayer, + List[] leftNeighborEdges) + { + var markedEdges = new HashSet(); + var maxLayer = groups.Count - 1; + + // Examine middle layers: i is the source side, i+1 is the target side. + for (var i = 1; i < maxLayer; i++) + { + var leftLayerSize = groups[i].Count; + var rightLayer = groups[i + 1]; + + // k0: lower bound of the permitted source-position range for the current batch. + var k0 = 0; + + // l: left cursor into the right layer (start of the current batch). + var l = 0; + + for (var l1 = 0; l1 < rightLayer.Count; l1++) + { + var v = rightLayer[l1]; + var incident = IsIncidentToInnerSegment(v, augNodes, augEdges, leftNeighborEdges); + + // Flush the batch at the last node or at each inner-segment anchor. + if (l1 != rightLayer.Count - 1 && !incident) + { + continue; + } + + // k1: upper bound of the permitted source-position range for this batch. + var k1 = leftLayerSize - 1; + if (incident) + { + var innerEdge = leftNeighborEdges[v][0]; + k1 = posInLayer[augEdges[innerEdge].Source]; + } + + // Mark non-inner segments in the batch whose sources are out of range. + while (l <= l1) + { + var vl = rightLayer[l]; + if (!IsIncidentToInnerSegment(vl, augNodes, augEdges, leftNeighborEdges)) + { + foreach (var edgeIdx in leftNeighborEdges[vl]) + { + var k = posInLayer[augEdges[edgeIdx].Source]; + if (k < k0 || k > k1) + { + markedEdges.Add(edgeIdx); + } + } + } + + l++; + } + + k0 = k1; + } + } + + return markedEdges; + } + + /// + /// Performs vertical alignment for one Brandes-Köpf layout direction, building the + /// circular block-chain structure that groups co-aligned nodes into blocks. + /// + /// + /// + /// Implements ELK's BKAligner.verticalAlignment. Each node v is aligned with the + /// median neighbor in the previous (RIGHT) or next (LEFT) layer that is still + /// unaligned (align[v] == v) and whose layer-position satisfies the monotone + /// constraint r. The monotone constraint prevents crossings between aligned pairs. + /// + /// + /// On output, [i] identifies the block root for every node i, + /// and [i] is the next node in the circular chain + /// (root → n1 → n2 → … → root). + /// + /// + private static void BkVerticalAlignment( + List augNodes, + List> groups, + List augEdges, + int[] posInLayer, + List[] leftNeighborEdges, + List[] rightNeighborEdges, + HashSet markedEdges, + bool vDown, + bool hRight, + out int[] root, + out int[] align) + { + var numAug = augNodes.Count; + var maxLayer = groups.Count - 1; + + // Every node starts as its own singleton block. + root = new int[numAug]; + align = new int[numAug]; + for (var i = 0; i < numAug; i++) + { + root[i] = i; + align[i] = i; + } + + // Layer iteration order: RIGHT scans forward (0..maxLayer); LEFT scans in reverse. + var layerStart = hRight ? 0 : maxLayer; + var layerEnd = hRight ? maxLayer : 0; + var layerStep = hRight ? 1 : -1; + + for (var l = layerStart; hRight ? l <= layerEnd : l >= layerEnd; l += layerStep) + { + var layer = groups[l]; + + // r: monotone position constraint; tracks the last aligned neighbor's position. + var r = vDown ? -1 : int.MaxValue; + + // Node iteration order: DOWN scans forward (0..N-1); UP scans in reverse. + var nodeStart = vDown ? 0 : layer.Count - 1; + var nodeEnd = vDown ? layer.Count - 1 : 0; + var nodeStep = vDown ? 1 : -1; + + for (var ni = nodeStart; vDown ? ni <= nodeEnd : ni >= nodeEnd; ni += nodeStep) + { + var v = layer[ni]; + var neighbors = hRight ? leftNeighborEdges[v] : rightNeighborEdges[v]; + var d = neighbors.Count; + if (d == 0) + { + continue; + } + + // Median index range for this node's neighbor list. + var low = (int)Math.Floor((d + 1) / 2.0) - 1; + var high = (int)Math.Ceiling((d + 1) / 2.0) - 1; + + // Try median neighbors in vdir order; stop as soon as v is aligned. + var mStart = vDown ? low : high; + var mEnd = vDown ? high : low; + var mStep = vDown ? 1 : -1; + + for (var m = mStart; vDown ? m <= mEnd : m >= mEnd; m += mStep) + { + // Stop iterating once v has been aligned with a neighbor. + if (align[v] != v) + { + break; + } + + var edgeIdx = neighbors[m]; + var uIdx = hRight ? augEdges[edgeIdx].Source : augEdges[edgeIdx].Target; + + if (markedEdges.Contains(edgeIdx)) + { + continue; + } + + var pos = posInLayer[uIdx]; + if (vDown ? r < pos : r > pos) + { + // Extend the block chain: insert v between uIdx and the current root. + align[uIdx] = v; + root[v] = root[uIdx]; + align[v] = root[v]; + r = pos; + } + } + } + } + } + + /// + /// Computes the inside-block shift for each node: the vertical offset relative to its + /// block root that makes the connecting ports co-linear within the block. + /// + /// + /// + /// Implements ELK's BKAligner.insideBlockShift. For each block root, walks the circular + /// align chain accumulating port-position differences between consecutive nodes. The + /// accumulated shifts are then normalized so the topmost node in the block has + /// innerShift = 0, making blockY the absolute Y of the block's highest point. + /// + /// + /// For hdir=RIGHT the edge between consecutive chain nodes goes source → target (earlier + /// layer to later layer), so portDiff = srcRelPortY − tgtRelPortY. For hdir=LEFT the + /// direction reverses, so portDiff = tgtRelPortY − srcRelPortY. + /// + /// + private static double[] BkInsideBlockShift( + List augNodes, + List augEdges, + int[] root, + int[] align, + double[] srcRelPortY, + double[] tgtRelPortY, + bool hRight, + List[] rightNeighborEdges, + List[] leftNeighborEdges) + { + var numAug = augNodes.Count; + var innerShift = new double[numAug]; + + // Process each block identified by its root node. + for (var r = 0; r < numAug; r++) + { + if (root[r] != r) + { + continue; + } + + // Walk the circular chain accumulating port-difference shifts. + var spaceAbove = 0.0; + var spaceBelow = augNodes[r].Height; + + var current = r; + var next = align[r]; + while (next != r) + { + // Locate the augmented edge that links consecutive block-chain nodes. + // For RIGHT: edge current → next (earlier → later layer). + // For LEFT: edge next → current (earlier → later layer, chain walks backward). + var edgeIdx = hRight + ? BkFindEdge(rightNeighborEdges[current], augEdges, next, findByTarget: true) + : BkFindEdge(leftNeighborEdges[current], augEdges, next, findByTarget: false); + + // Port alignment: accumulate the source-minus-target port offset. + var portDiff = hRight + ? srcRelPortY[edgeIdx] - tgtRelPortY[edgeIdx] + : tgtRelPortY[edgeIdx] - srcRelPortY[edgeIdx]; + + innerShift[next] = innerShift[current] + portDiff; + spaceAbove = Math.Max(spaceAbove, -innerShift[next]); + spaceBelow = Math.Max(spaceBelow, innerShift[next] + augNodes[next].Height); + + current = next; + next = align[current]; + } + + // Normalize: add spaceAbove to all shifts so the topmost node is at offset 0. + if (spaceAbove > 0.0) + { + var node = r; + do + { + innerShift[node] += spaceAbove; + node = align[node]; + } + while (node != r); + } + } + + return innerShift; + } + + /// + /// Finds the augmented edge in that connects to + /// , searching by target when + /// is true, or by source otherwise. + /// + /// + /// Used by to locate the edge that links consecutive + /// nodes in a block chain. Because block chains are built strictly along real augmented + /// edges, the edge is always present; returning −1 would indicate a logic error in the + /// vertical-alignment phase. + /// + private static int BkFindEdge( + List edgeList, + List augEdges, + int matchNode, + bool findByTarget) + { + foreach (var ei in edgeList) + { + if ((findByTarget ? augEdges[ei].Target : augEdges[ei].Source) == matchNode) + { + return ei; + } + } + + return -1; // Should not occur: block chains are always built along real edges. + } + + /// + /// Assigns an absolute Y coordinate (blockY) to each block root by compacting blocks + /// in the vertical direction, respecting per-node heights and gaps. + /// + /// + /// + /// Implements ELK's BKCompactor.horizontalCompaction / placeBlock. For vdir=DOWN, + /// blocks are packed top-to-bottom starting at Y = 0, constrained from above by the + /// node immediately above each chain member in its layer. For vdir=UP, blocks are + /// packed bottom-to-top starting at Y = 0, constrained from below (yielding negative + /// values that are normalized in ). + /// + /// + /// The local PlaceBlock function places a block root's Y coordinate by recursively + /// ensuring every constraining block is placed first (memoized via a NaN sentinel). + /// + /// + private static double[] BkHorizontalCompaction( + List augNodes, + List> groups, + int[] root, + int[] align, + double[] innerShift, + int[] posInLayer, + bool vDown) + { + var maxLayer = groups.Count - 1; + var blockY = new double[augNodes.Count]; + Array.Fill(blockY, double.NaN); + + // Recursively place a block root, memoized by the NaN-unplaced sentinel. + void PlaceBlock(int v) + { + if (!double.IsNaN(blockY[v])) + { + return; + } + + blockY[v] = 0.0; + + // Enforce separation constraints for every node in this block's chain. + var current = v; + do + { + var layer = augNodes[current].Layer; + var idx = posInLayer[current]; + var layerNodes = groups[layer]; + + if (vDown) + { + // DOWN: constrain from above — current must be below the node at idx−1. + if (idx > 0) + { + var above = layerNodes[idx - 1]; + var aboveRoot = root[above]; + PlaceBlock(aboveRoot); + + var requiredY = blockY[aboveRoot] + + innerShift[above] + + augNodes[above].Height + + NodeSpacing + - innerShift[current]; + blockY[v] = Math.Max(blockY[v], requiredY); + } + } + else + { + // UP: constrain from below — current must be above the node at idx+1. + if (idx < layerNodes.Count - 1) + { + var below = layerNodes[idx + 1]; + var belowRoot = root[below]; + PlaceBlock(belowRoot); + + var requiredY = blockY[belowRoot] + + innerShift[below] + - NodeSpacing + - augNodes[current].Height + - innerShift[current]; + blockY[v] = Math.Min(blockY[v], requiredY); + } + } + + current = align[current]; + } + while (current != v); + } + + // Trigger placement for all block roots in vdir processing order. + var layerStart = vDown ? 0 : maxLayer; + var layerEnd = vDown ? maxLayer : 0; + var layerStep = vDown ? 1 : -1; + + for (var l = layerStart; vDown ? l <= layerEnd : l >= layerEnd; l += layerStep) + { + var layer = groups[l]; + var nodeStart = vDown ? 0 : layer.Count - 1; + var nodeEnd = vDown ? layer.Count - 1 : 0; + var nodeStep = vDown ? 1 : -1; + + for (var ni = nodeStart; vDown ? ni <= nodeEnd : ni >= nodeEnd; ni += nodeStep) + { + var v = layer[ni]; + if (root[v] == v) + { + PlaceBlock(v); + } + } + } + + return blockY; + } + + /// + /// Normalizes four independent Brandes-Köpf layouts (each shifted so its minimum Y = 0) + /// and returns the per-node average of the two middle values, giving the balanced result. + /// + /// + /// Implements ELK's BKNodePlacer balanced-layout combination. Each of the four layouts + /// has a distinct direction bias (DOWN/UP × RIGHT/LEFT); the median average cancels + /// those biases while preserving the port-alignment constraints of each individual + /// layout. is added once so all returned coordinates are absolute. + /// + private static double[] BkBalancedLayout(double[][] layouts, int numAug) + { + // Normalize each layout: shift so the minimum absolute Y across all nodes is 0. + foreach (var y in layouts) + { + var minY = y.Min(); + for (var i = 0; i < numAug; i++) + { + y[i] -= minY; + } + } + + // For each node, sort the four Y values and average the two middle ones. + var finalY = new double[numAug]; + for (var i = 0; i < numAug; i++) + { + var ys = new[] { layouts[0][i], layouts[1][i], layouts[2][i], layouts[3][i] }; + Array.Sort(ys); + finalY[i] = ((ys[1] + ys[2]) / 2.0) + Padding; + } + + return finalY; + } + + // ── Phase 4: Port distribution + ELK slot routing ──────────────────────── + + /// + /// Distributes ports on each box face and routes all corridors using ELK's + /// OrthogonalRoutingGenerator slot algorithm. + /// + private static IReadOnlyList> BuildRoutesAug( + int n, + IReadOnlyList nodes, + List augNodes, + List augEdges, + List acyclic, + double[] augX, + double[] augY, + double[] columnX, + double[] maxColWidth) + { + var numAugEdges = augEdges.Count; + var numOrigEdges = acyclic.Count; + + // Port Y values: augPortYSrc[i] = source (right face) Y; augPortYTgt[i] = target (left face) Y. + var augPortYSrc = new double[numAugEdges]; + var augPortYTgt = new double[numAugEdges]; + + // Distribute outgoing (source-side) ports on each real node's right face. + var outByNode = new Dictionary>(); + for (var ei = 0; ei < numAugEdges; ei++) + { + var src = augEdges[ei].Source; + if (!outByNode.TryGetValue(src, out var list)) + { + list = []; + outByNode[src] = list; + } + + list.Add(ei); + } + + foreach (var (ni, edgeList) in outByNode) + { + if (augNodes[ni].IsDummy) + { + // Dummies pass the wire straight through at their own Y. + foreach (var ei in edgeList) + { + augPortYSrc[ei] = augY[ni]; + } + } + else + { + // Sort by target Y center, then edge index for stability. + var sorted = edgeList + .OrderBy(ei => augY[augEdges[ei].Target] + (augNodes[augEdges[ei].Target].Height / 2.0)) + .ThenBy(ei => ei) + .ToList(); + DistributePorts(sorted, augY[ni], nodes[ni].Height, augPortYSrc); + } + } + + // Distribute incoming (target-side) ports on each real node's left face. + var inByNode = new Dictionary>(); + for (var ei = 0; ei < numAugEdges; ei++) + { + var tgt = augEdges[ei].Target; + if (!inByNode.TryGetValue(tgt, out var list)) + { + list = []; + inByNode[tgt] = list; + } + + list.Add(ei); + } + + foreach (var (ni, edgeList) in inByNode) + { + if (augNodes[ni].IsDummy) + { + foreach (var ei in edgeList) + { + augPortYTgt[ei] = augY[ni]; + } + } + else + { + var sorted = edgeList + .OrderBy(ei => augY[augEdges[ei].Source] + (augNodes[augEdges[ei].Source].Height / 2.0)) + .ThenBy(ei => ei) + .ToList(); + DistributePorts(sorted, augY[ni], nodes[ni < n ? ni : 0].Height, augPortYTgt); + } + } + + // Route each corridor using ELK's slot algorithm. + var layerCount = columnX.Length; + var augBendPoints = new List[numAugEdges]; + for (var ei = 0; ei < numAugEdges; ei++) + { + augBendPoints[ei] = []; + } + + for (var l = 0; l + 1 < layerCount; l++) + { + // Collect sub-edges whose source is in layer l. + var corridorEdges = augEdges + .Select((ae, i) => (ae, i)) + .Where(x => augNodes[x.ae.Source].Layer == l) + .ToList(); + + if (corridorEdges.Count == 0) + { + continue; + } + + // First slot starts one ConnectorClearance past the right edge of the source column. + var startPos = columnX[l] + maxColWidth[l] + ConnectorClearance; + + // Build routing segments. + var segments = corridorEdges + .Select(x => + { + var srcY = augPortYSrc[x.i]; + var tgtY = augPortYTgt[x.i]; + return new Segment + { + AugEdgeIndex = x.i, + SourceY = srcY, + TargetY = tgtY, + Lo = Math.Min(srcY, tgtY), + Hi = Math.Max(srcY, tgtY), + }; + }) + .ToList(); + + // Build crossing-based dependencies between segment pairs (ELK countCrossings). + for (var i = 0; i < segments.Count - 1; i++) + { + for (var j = i + 1; j < segments.Count; j++) + { + CreateDependency(segments[i], segments[j]); + } + } + + // Break cycles in the dependency graph by removing back edges. + BreakSegmentCycles(segments); + + // Assign routing slots via topological BFS (ELK topologicalNumbering). + TopologicalNumbering(segments); + + // Emit bend points: ELK WestToEastRoutingStrategy.calculateBendPoints. + foreach (var seg in segments) + { + if (Math.Abs(seg.SourceY - seg.TargetY) < StraightTolerance) + { + // Straight edge: no bend points, no slot consumed. + continue; + } + + var segX = startPos + (seg.RoutingSlot * EdgeSpacing); + augBendPoints[seg.AugEdgeIndex] = + [ + new Point2D(segX, seg.SourceY), + new Point2D(segX, seg.TargetY), + ]; + } + } + + // Assemble per-original-edge waypoints from sub-edge bend points (ELK LongEdgeJoiner). + var subEdgesByOrig = new List[numOrigEdges]; + for (var ei = 0; ei < numOrigEdges; ei++) + { + subEdgesByOrig[ei] = []; + } + + for (var ei = 0; ei < numAugEdges; ei++) + { + subEdgesByOrig[augEdges[ei].OrigEdgeIndex].Add(ei); + } + + // Sub-edges must be in source-to-target layer order for concatenation. + for (var ei = 0; ei < numOrigEdges; ei++) + { + subEdgesByOrig[ei].Sort((a, b) => + augNodes[augEdges[a].Source].Layer.CompareTo(augNodes[augEdges[b].Source].Layer)); + } + + var result = new IReadOnlyList[numOrigEdges]; + for (var origIdx = 0; origIdx < numOrigEdges; origIdx++) + { + var subEdges = subEdgesByOrig[origIdx]; + if (subEdges.Count == 0) + { + result[origIdx] = []; + continue; + } + + var firstSubEdge = augEdges[subEdges[0]]; + var lastSubEdge = augEdges[subEdges[^1]]; + + var srcNodeIdx = firstSubEdge.Source; + var tgtNodeIdx = lastSubEdge.Target; + + var srcRight = augX[srcNodeIdx] + augNodes[srcNodeIdx].Width; + var tgtLeft = augX[tgtNodeIdx]; + var srcPortY = augPortYSrc[subEdges[0]]; + var tgtPortY = augPortYTgt[subEdges[^1]]; + + var wps = new List + { + new(srcRight, srcPortY), + }; + + foreach (var subEdgeIdx in subEdges) + { + wps.AddRange(augBendPoints[subEdgeIdx]); + } + + wps.Add(new Point2D(tgtLeft, tgtPortY)); + result[origIdx] = wps; + } + + return result; + } + + // ── ELK OrthogonalRoutingGenerator: dependency creation ────────────────── + + /// + /// Creates a directed dependency between two segments based on ELK's crossing-count heuristic + /// (countCrossings): the segment whose left placement causes fewer crossings becomes the + /// source (lower slot) of the dependency. + /// + /// + /// For 1:1 edges each segment has exactly one EAST port (SourceY, ELK incomingConnectionCoordinates) + /// and one WEST port (TargetY, ELK outgoingConnectionCoordinates). The crossing count is computed + /// per ELK's countCrossings(posis, start, end): number of positions in posis that fall + /// within [start, end]. + /// + private static void CreateDependency(Segment s1, Segment s2) + { + // crossings1: cost of placing s1 LEFT of s2. + // outgoing(s1)=[s1.TargetY] ∩ [s2.Lo, s2.Hi] → does s1's exit cross s2's extent? + // incoming(s2)=[s2.SourceY] ∩ [s1.Lo, s1.Hi] → does s2's entry fall inside s1's extent? + var c1 = CountCrossings(s1.TargetY, s2.Lo, s2.Hi) + + CountCrossings(s2.SourceY, s1.Lo, s1.Hi); + + // crossings2: cost of placing s2 LEFT of s1. + // outgoing(s2)=[s2.TargetY] ∩ [s1.Lo, s1.Hi] + // incoming(s1)=[s1.SourceY] ∩ [s2.Lo, s2.Hi] + var c2 = CountCrossings(s2.TargetY, s1.Lo, s1.Hi) + + CountCrossings(s1.SourceY, s2.Lo, s2.Hi); + + if (c1 < c2) + { + // s1 prefers to be left of s2. + _ = new SegDep(s1, s2, c2 - c1); + } + else if (c2 < c1) + { + // s2 prefers to be left of s1. + _ = new SegDep(s2, s1, c1 - c2); + } + else if (c1 > 0) + { + // Equal non-zero crossings: unavoidable conflict — pick s1 left (deterministic tie-break). + _ = new SegDep(s1, s2, 0); + } + + // c1 == c2 == 0: both orderings cross-free; no dependency needed. + } + + /// + /// Returns 1 if falls within [, + /// ], 0 otherwise. Implements ELK's single-position + /// countCrossings. + /// + private static int CountCrossings(double pos, double lo, double hi) + => (pos >= lo && pos <= hi) ? 1 : 0; + + // ── ELK OrthogonalRoutingGenerator: cycle breaking ─────────────────────── + + /// + /// Detects and removes back edges in the dependency graph using DFS coloring, following + /// ELK's breakNonCriticalCycles. Removing a back edge accepts one additional crossing + /// in exchange for an acyclic ordering. + /// + private static void BreakSegmentCycles(List segments) + { + // 0 = unvisited, 1 = on stack (gray), 2 = done (black). + var color = new Dictionary(); + + void Dfs(Segment s) + { + color[s] = 1; + + // Snapshot the list before iterating to allow safe removal. + foreach (var dep in s.Outgoing.ToList()) + { + var t = dep.Target; + if (!color.TryGetValue(t, out var c)) + { + color[t] = 0; + Dfs(t); + } + else if (c == 1) + { + // Back edge detected: remove it to break the cycle. + dep.Remove(); + } + } + + // S4143: standard DFS coloring across recursion boundary. +#pragma warning disable S4143 + color[s] = 2; +#pragma warning restore S4143 + } + + foreach (var s in segments.Where(s => !color.ContainsKey(s))) + { + color[s] = 0; + Dfs(s); + } + } + + // ── ELK OrthogonalRoutingGenerator: topological numbering ──────────────── + + /// + /// Assigns routing slots to segments by topological BFS, implementing ELK's + /// topologicalNumbering. Each segment's slot equals the maximum predecessor slot + /// plus one (Kahn's algorithm on the dependency DAG). + /// + private static void TopologicalNumbering(List segments) + { + // Reset weights from the current (post-cycle-breaking) dependency graph. + foreach (var s in segments) + { + s.InWeight = s.Incoming.Count; + s.RoutingSlot = 0; + } + + var sources = segments.Where(s => s.InWeight == 0).ToList(); + + while (sources.Count > 0) + { + var node = sources[0]; + sources.RemoveAt(0); + + foreach (var tgt in node.Outgoing.Select(dep => dep.Target)) + { + tgt.RoutingSlot = Math.Max(tgt.RoutingSlot, node.RoutingSlot + 1); + tgt.InWeight--; + if (tgt.InWeight == 0) + { + sources.Add(tgt); + } + } + } + + // Any segments unreachable due to remaining cycles (should not occur after cycle-breaking) + // keep slot 0 and will be routed at the first available position. + } + + // ── Port distribution ───────────────────────────────────────────────────── + + /// + /// Evenly distributes port Y positions along a node face, with + /// inset from the top and bottom edges. + /// + private static void DistributePorts( + IReadOnlyList sortedEdgeIndices, + double nodeTop, + double nodeHeight, + double[] portY) + { + var count = sortedEdgeIndices.Count; + for (var k = 0; k < count; k++) + { + double y; + if (count == 1) + { + y = nodeTop + (nodeHeight / 2.0); + } + else + { + var usable = nodeHeight - (2.0 * ConnectorClearance); + y = nodeTop + ConnectorClearance + (k * usable / (count - 1)); + } + + portY[sortedEdgeIndices[k]] = Math.Clamp( + y, + nodeTop + ConnectorClearance, + nodeTop + nodeHeight - ConnectorClearance); + } + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeJoinerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeJoinerTests.cs new file mode 100644 index 0000000..3a0bc9c --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeJoinerTests.cs @@ -0,0 +1,81 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering production of one polyline per original edge +/// and concatenation of a long edge's sub-edge bend points. +/// +public sealed class LongEdgeJoinerTests +{ + /// + /// A single short edge yields one waypoint polyline of exactly two points (its source and + /// target ports, with no bends). + /// + [Fact] + public void LongEdgeJoiner_Apply_SingleEdge_ProducesWaypointsPerOriginalEdge() + { + // Arrange / Act: join a single 0->1 edge. + var graph = BuildJoinedGraph( + new List { new(60, 40), new(60, 40) }, + new List { new(0, 1) }); + + // Assert: one polyline of two points. + Assert.Single(graph.Waypoints); + Assert.Equal(2, graph.Waypoints[0].Count); + } + + /// + /// A span-three edge's polyline begins at the source's right face and ends at the target's + /// left face, and its point count equals its sub-edges' bend points plus the two endpoints. + /// + [Fact] + public void LongEdgeJoiner_Apply_LongEdge_ConcatenatesSubEdgeBendPoints() + { + // Arrange: a chain plus a span-three edge 0->3 (original edge index 3). + var nodes = new List { new(60, 40), new(60, 40), new(60, 40), new(60, 40) }; + var graph = BuildJoinedGraph(nodes, new List { new(0, 1), new(1, 2), new(2, 3), new(0, 3) }); + + const int origIdx = 3; + var polyline = graph.Waypoints[origIdx]; + + // Sub-edge bend points that make up the long edge. + var bendTotal = 0; + for (var ei = 0; ei < graph.AugEdges.Count; ei++) + { + if (graph.AugEdges[ei].OrigEdgeIndex == origIdx) + { + bendTotal += graph.AugBendPoints[ei].Count; + } + } + + // Assert: one polyline per original edge, anchored to the boxes, count = endpoints + bends. + Assert.Equal(4, graph.Waypoints.Count); + Assert.Equal(graph.AugX[0] + nodes[0].Width, polyline[0].X); + Assert.Equal(graph.AugX[3], polyline[^1].X); + Assert.Equal(bendTotal + 2, polyline.Count); + } + + /// Runs the stages up to and including long-edge joining and returns the graph. + /// Input nodes. + /// Input edges. + /// The graph after the long-edge-joining stage. + private static LayeredGraph BuildJoinedGraph(List nodes, List edges) + { + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + new CrossingMinimizer().Apply(graph); + new BrandesKopfPlacer().Apply(graph); + new PortDistributor().Apply(graph); + new LayeredCorridorRouter().Apply(graph); + new LongEdgeJoiner().Apply(graph); + return graph; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeSplitterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeSplitterTests.cs new file mode 100644 index 0000000..6a99c4c --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeSplitterTests.cs @@ -0,0 +1,59 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering that unit-span edges add no dummy nodes +/// and that a long edge inserts one dummy node per intermediate layer. +/// +public sealed class LongEdgeSplitterTests +{ + /// + /// A chain of unit-span edges (0->1->2) produces an augmented node list equal in + /// count to the input node list, with no dummy nodes. + /// + [Fact] + public void LongEdgeSplitter_Apply_SpanOneEdge_AddsNoDummyNodes() + { + // Arrange: a three-node chain (all edges span one layer). + var nodes = new List { new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(1, 2) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act: run the prerequisite stages, then split long edges. + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + + // Assert: no dummy nodes were added. + Assert.Equal(graph.N, graph.AugNodes.Count); + Assert.DoesNotContain(graph.AugNodes, a => a.IsDummy); + } + + /// + /// A span-three edge (0->3 over the chain 0->1->2->3) is split with one dummy + /// node in each of the two intermediate layers. + /// + [Fact] + public void LongEdgeSplitter_Apply_LongEdge_InsertsDummyNodesPerIntermediateLayer() + { + // Arrange: a chain plus a span-three edge 0->3. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1), new(1, 2), new(2, 3), new(0, 3) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + + // Act: run the prerequisite stages, then split long edges. + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + + // Assert: exactly two dummy nodes were inserted (layers 1 and 2). + Assert.Equal(graph.N + 2, graph.AugNodes.Count); + Assert.Equal(2, graph.AugNodes.Count(a => a.IsDummy)); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterBackEdgeTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterBackEdgeTests.cs new file mode 100644 index 0000000..15ae0d2 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterBackEdgeTests.cs @@ -0,0 +1,318 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; +using static DemaConsulting.Rendering.Layout.Engine.Layered.LayeredLayoutMetrics; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for the reversed (back) edge entry-approach clamp in , +/// driven by the parameter. A reversed edge is +/// stored flipped, so the consumer draws the end marker on the augmented-source face of the first +/// sub-edge; the clamp guarantees that final straight approach is at least +/// long so the marker reads as a clean +/// perpendicular entry. The clamp uses Math.Max, so at the default approach (equal to +/// ) it is a provable no-op and forward and +/// acyclic geometry stay byte-identical to the original engine. +/// +public sealed class OrthogonalRouterBackEdgeTests +{ + /// + /// At the default approach the clamp is a no-op: every edge in a cyclic graph (forward and + /// reversed alike) is byte-for-byte identical to the true-original reference engine. + /// + [Fact] + public void OrthogonalRouter_DefaultApproach_IsByteIdenticalToLegacy() + { + var nodes = new List + { + new(120, 50), new(150, 60), new(130, 40), new(140, 70), new(110, 55), + }; + var edges = new List + { + new(0, 1), new(1, 2), new(2, 3), new(3, 4), new(4, 0), + }; + + // The product engine uses the default BackEdgeEntryApproach (== ConnectorClearance), so it + // must reproduce the unmodified reference engine exactly, reversed edges included. + var expected = LegacyInterconnectionLayoutEngineOracle.Place(nodes, edges).ConnectorWaypoints; + var actual = InterconnectionLayoutEngine.Place(nodes, edges).ConnectorWaypoints; + + Assert.Equal(expected.Count, actual.Count); + for (var e = 0; e < expected.Count; e++) + { + AssertWaypointsBitIdentical($"edge {e}", expected[e], actual[e]); + } + } + + /// + /// Raising pushes every reversed edge's first + /// (marker-side) straight segment outward to at least the requested approach — longer than the + /// default — so a longer end decoration always sits on a clean straight leg. + /// + [Fact] + public void OrthogonalRouter_CustomApproach_PushesEntryStubOutward() + { + // Arrange: a five-node cycle forces a surviving reversed (long) edge. + var nodes = new List + { + new(120, 50), new(150, 60), new(130, 40), new(140, 70), new(110, 55), + }; + var edges = new List + { + new(0, 1), new(1, 2), new(2, 3), new(3, 4), new(4, 0), + }; + + const double customApproach = 40.0; + Assert.True(customApproach > ConnectorClearance, "Test requires a custom approach above the default."); + + // Act: route once at the default approach and once at the larger custom approach. + var defaultGraph = BuildJoinedGraph(nodes, edges, ConnectorClearance); + var customGraph = BuildJoinedGraph(nodes, edges, customApproach); + + // Assert: every reversed bent edge clears the custom threshold, and at least one such edge is + // strictly farther out than at the default (proving the parameter actually moved geometry). + var reversedBendCount = 0; + var movedCount = 0; + for (var e = 0; e < customGraph.Acyclic.Count; e++) + { + if (!customGraph.AcyclicReversed[e]) + { + continue; + } + + var customWaypoints = customGraph.Waypoints[e]; + var defaultWaypoints = defaultGraph.Waypoints[e]; + + // A bent edge has interior waypoints; the first segment is the marker-side stub. + if (customWaypoints.Count < 3) + { + continue; + } + + reversedBendCount++; + var customFirst = Math.Abs(customWaypoints[1].X - customWaypoints[0].X); + Assert.True( + customFirst >= customApproach, + $"Reversed edge {e} entry approach {customFirst:R} < custom approach {customApproach:R}."); + + if (defaultWaypoints.Count >= 3) + { + var defaultFirst = Math.Abs(defaultWaypoints[1].X - defaultWaypoints[0].X); + if (customFirst > defaultFirst) + { + movedCount++; + } + } + } + + Assert.True(reversedBendCount > 0, "Expected at least one reversed edge with a routing bend."); + Assert.True(movedCount > 0, "Expected the custom approach to push at least one reversed stub outward."); + } + + /// + /// At the default approach every reversed edge that bends still produces a first + /// (marker-side) straight segment at least + /// long — the original engine's behavior, preserved. + /// + [Fact] + public void OrthogonalRouter_ReversedEdge_DefaultApproachClearsClearance() + { + var nodes = new List + { + new(120, 50), new(150, 60), new(130, 40), new(140, 70), new(110, 55), + }; + var edges = new List + { + new(0, 1), new(1, 2), new(2, 3), new(3, 4), new(4, 0), + }; + var graph = BuildJoinedGraph(nodes, edges, ConnectorClearance); + + var reversedBendCount = 0; + for (var e = 0; e < graph.Acyclic.Count; e++) + { + if (!graph.AcyclicReversed[e]) + { + continue; + } + + var waypoints = graph.Waypoints[e]; + if (waypoints.Count < 3) + { + continue; + } + + reversedBendCount++; + var firstSegment = Math.Abs(waypoints[1].X - waypoints[0].X); + Assert.True( + firstSegment >= ConnectorClearance, + $"Reversed edge {e} entry approach {firstSegment:R} < ConnectorClearance {ConnectorClearance:R}."); + } + + Assert.True(reversedBendCount > 0, "Expected at least one reversed edge with a routing bend."); + } + + /// + /// In a cyclic graph, every forward (non-reversed) edge's waypoints stay byte-for-byte + /// identical to the reference engine — the clamp never touches forward geometry. + /// + [Fact] + public void OrthogonalRouter_ForwardEdges_GeometryUnchanged() + { + var nodes = new List + { + new(120, 50), new(150, 60), new(130, 40), new(140, 70), new(110, 55), + }; + var edges = new List + { + new(0, 1), new(1, 2), new(2, 3), new(3, 4), new(4, 0), + }; + + var reversed = ReversedFlags(nodes, edges); + var expected = LegacyInterconnectionLayoutEngineOracle.Place(nodes, edges).ConnectorWaypoints; + var actual = InterconnectionLayoutEngine.Place(nodes, edges).ConnectorWaypoints; + + Assert.Equal(expected.Count, actual.Count); + var forwardChecked = 0; + for (var e = 0; e < reversed.Length; e++) + { + if (reversed[e]) + { + continue; + } + + forwardChecked++; + AssertWaypointsBitIdentical($"forward edge {e}", expected[e], actual[e]); + } + + Assert.True(forwardChecked > 0, "Expected at least one forward edge in the cyclic graph."); + } + + /// + /// An acyclic graph has no reversed edges, so the clamp is a no-op: the reversed flags are + /// all false and every waypoint is byte-for-byte identical to the reference engine. + /// + [Fact] + public void OrthogonalRouter_AcyclicGraph_NoApproachChange() + { + var nodes = new List + { + new(120, 50), new(150, 60), new(130, 40), new(140, 70), + }; + var edges = new List + { + new(0, 1), new(0, 2), new(1, 3), new(2, 3), new(0, 3), + }; + + var reversed = ReversedFlags(nodes, edges); + Assert.All(reversed, flag => Assert.False(flag)); + + var expected = LegacyInterconnectionLayoutEngineOracle.Place(nodes, edges).ConnectorWaypoints; + var actual = InterconnectionLayoutEngine.Place(nodes, edges).ConnectorWaypoints; + + Assert.Equal(expected.Count, actual.Count); + for (var e = 0; e < expected.Count; e++) + { + AssertWaypointsBitIdentical($"acyclic edge {e}", expected[e], actual[e]); + } + } + + /// + /// Decoration-aware approach: when the back-edge entry approach is derived exactly as the + /// state-transition view derives it — the longest end-marker along-line length plus the corner + /// radius plus the clean-leg margin — every reversed bent edge presents a final straight leg at + /// least as long as the open-chevron decoration, so the rounded corner never intrudes the marker. + /// + [Fact] + public void OrthogonalRouter_DecorationAwareApproach_ClearsMarkerAlongLength() + { + var nodes = new List + { + new(120, 50), new(150, 60), new(130, 40), new(140, 70), new(110, 55), + }; + var edges = new List + { + new(0, 1), new(1, 2), new(2, 3), new(3, 4), new(4, 0), + }; + + // Derive the approach exactly as StateTransitionViewLayoutStrategy does for its end markers. + var theme = Themes.Light; + var markerAlongLength = NotationMetrics.AlongLineLength(EndMarkerStyle.OpenChevron); + var approach = markerAlongLength + theme.LineCornerRadius + theme.CleanLegMargin; + + var graph = BuildJoinedGraph(nodes, edges, approach); + + var reversedBendCount = 0; + for (var e = 0; e < graph.Acyclic.Count; e++) + { + if (!graph.AcyclicReversed[e]) + { + continue; + } + + var waypoints = graph.Waypoints[e]; + if (waypoints.Count < 3) + { + continue; + } + + reversedBendCount++; + var firstSegment = Math.Abs(waypoints[1].X - waypoints[0].X); + Assert.True( + firstSegment >= markerAlongLength, + $"Reversed edge {e} clean leg {firstSegment:R} < marker along length {markerAlongLength:R}."); + } + + Assert.True(reversedBendCount > 0, "Expected at least one reversed edge with a routing bend."); + } + + /// + /// Runs the pipeline stages up to and including long-edge joining at the requested back-edge + /// entry approach and returns the graph. + /// + private static LayeredGraph BuildJoinedGraph(List nodes, List edges, double approach) + { + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right) + { + BackEdgeEntryApproach = approach, + }; + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + new CrossingMinimizer().Apply(graph); + new BrandesKopfPlacer().Apply(graph); + new PortDistributor().Apply(graph); + new LayeredCorridorRouter().Apply(graph); + new LongEdgeJoiner().Apply(graph); + return graph; + } + + /// Computes the reversed-edge flags (parallel to the acyclic edge set) for an input. + private static bool[] ReversedFlags(List nodes, List edges) + { + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + return graph.AcyclicReversed; + } + + /// Asserts two waypoint polylines are bit-for-bit identical (no tolerance). + private static void AssertWaypointsBitIdentical( + string context, + IReadOnlyList expected, + IReadOnlyList actual) + { + Assert.Equal(expected.Count, actual.Count); + for (var w = 0; w < expected.Count; w++) + { + Assert.True( + BitConverter.DoubleToInt64Bits(expected[w].X) == BitConverter.DoubleToInt64Bits(actual[w].X) + && BitConverter.DoubleToInt64Bits(expected[w].Y) == BitConverter.DoubleToInt64Bits(actual[w].Y), + $"{context}: waypoint {w} differs (expected ({expected[w].X:R},{expected[w].Y:R}), actual ({actual[w].X:R},{actual[w].Y:R}))."); + } + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs new file mode 100644 index 0000000..558fb26 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs @@ -0,0 +1,68 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering that an aligned edge produces no bend +/// points and that every bend list is empty or a two-point vertical segment. +/// +public sealed class OrthogonalRouterTests +{ + /// + /// A single edge between two equal-height nodes is routed straight, so it produces no bend + /// points (its ports are aligned by the placer). + /// + [Fact] + public void OrthogonalRouter_Apply_StraightEdge_ProducesNoBendPoints() + { + // Arrange / Act: route a single 0->1 edge. + var graph = BuildRoutedGraph( + new List { new(60, 40), new(60, 40) }, + new List { new(0, 1) }); + + // Assert: the single sub-edge has no bend points. + Assert.Single(graph.AugBendPoints); + Assert.Empty(graph.AugBendPoints[0]); + } + + /// + /// Every sub-edge's bend list is either empty (a straight run) or exactly two points that + /// share an X coordinate (a vertical routing segment). + /// + [Fact] + public void OrthogonalRouter_Apply_EveryBendListIsEmptyOrVerticalSegment() + { + // Arrange / Act: route a four-node diamond. + var graph = BuildRoutedGraph( + new List { new(60, 40), new(60, 40), new(60, 40), new(60, 40) }, + new List { new(0, 1), new(0, 2), new(1, 3), new(2, 3) }); + + // Assert: each bend list is empty or a vertical two-point segment. + Assert.All(graph.AugBendPoints, bend => + Assert.True( + bend.Count == 0 || (bend.Count == 2 && Math.Abs(bend[0].X - bend[1].X) < 1e-9), + $"Unexpected bend geometry with {bend.Count} points.")); + } + + /// Runs the stages up to and including orthogonal routing and returns the graph. + /// Input nodes. + /// Input edges. + /// The graph after the orthogonal-routing stage. + private static LayeredGraph BuildRoutedGraph(List nodes, List edges) + { + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + new CrossingMinimizer().Apply(graph); + new BrandesKopfPlacer().Apply(graph); + new PortDistributor().Apply(graph); + new LayeredCorridorRouter().Apply(graph); + return graph; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/PortDistributorTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/PortDistributorTests.cs new file mode 100644 index 0000000..8302677 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/PortDistributorTests.cs @@ -0,0 +1,67 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for covering that ports lie within node faces and that a +/// source and target port Y is recorded for every augmented sub-edge. +/// +public sealed class PortDistributorTests +{ + /// + /// A single edge's source and target ports lie within the faces of their respective nodes. + /// + [Fact] + public void PortDistributor_Apply_SingleEdge_PortsLieWithinNodeFaces() + { + // Arrange / Act: distribute ports for a single 0->1 edge. + var nodes = new List { new(60, 40), new(60, 40) }; + var graph = BuildPortedGraph(nodes, new List { new(0, 1) }); + + var src = graph.AugEdges[0].Source; + var tgt = graph.AugEdges[0].Target; + + // Assert: the source port is on the source node's face and the target port on the target's. + Assert.InRange(graph.AugPortYSrc[0], graph.AugY[src], graph.AugY[src] + nodes[src].Height); + Assert.InRange(graph.AugPortYTgt[0], graph.AugY[tgt], graph.AugY[tgt] + nodes[tgt].Height); + } + + /// + /// A diamond graph yields one source port Y and one target port Y per augmented sub-edge, + /// each a finite value. + /// + [Fact] + public void PortDistributor_Apply_AssignsPortYForEverySubEdge() + { + // Arrange / Act: distribute ports for a four-node diamond. + var nodes = new List { new(60, 40), new(60, 40), new(60, 40), new(60, 40) }; + var graph = BuildPortedGraph(nodes, new List { new(0, 1), new(0, 2), new(1, 3), new(2, 3) }); + + // Assert: one source and one target port per sub-edge. + Assert.Equal(graph.AugEdges.Count, graph.AugPortYSrc.Length); + Assert.Equal(graph.AugEdges.Count, graph.AugPortYTgt.Length); + Assert.All(graph.AugPortYSrc, y => Assert.True(double.IsFinite(y))); + Assert.All(graph.AugPortYTgt, y => Assert.True(double.IsFinite(y))); + } + + /// Runs the stages up to and including port distribution and returns the graph. + /// Input nodes. + /// Input edges. + /// The graph after the port-distribution stage. + private static LayeredGraph BuildPortedGraph(List nodes, List edges) + { + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + new LongEdgeSplitter().Apply(graph); + new CrossingMinimizer().Apply(graph); + new BrandesKopfPlacer().Apply(graph); + new PortDistributor().Apply(graph); + return graph; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs new file mode 100644 index 0000000..abdf7ed --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/OrthogonalEdgeRouterTests.cs @@ -0,0 +1,347 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine; + +/// +/// Tests for orthogonal edge routing. +/// +public sealed class OrthogonalEdgeRouterTests +{ + /// + /// A route with no obstacles still produces a valid orthogonal path from source to target. + /// + [Fact] + public void Route_NoObstacles_ProducesOrthogonalPath() + { + // Act: route between two diagonal points with no obstacles + var path = OrthogonalEdgeRouter.Route(new Point2D(0, 0), new Point2D(100, 80), [], clearance: 10); + + // Assert: path starts at source, ends at target, and every segment is axis-aligned + AssertEndpoints(path, new Point2D(0, 0), new Point2D(100, 80)); + AssertAllSegmentsOrthogonal(path); + } + + /// + /// With an obstacle directly between source and target, the route avoids the obstacle interior. + /// + [Fact] + public void Route_ObstacleBetween_RoutesAround() + { + // Arrange: an obstacle squarely between the horizontal line from source to target + var source = new Point2D(0, 50); + var target = new Point2D(200, 50); + var obstacles = new[] { new Rect(80, 0, 40, 100) }; + + // Act + var path = OrthogonalEdgeRouter.Route(source, target, obstacles, clearance: 10); + + // Assert: valid orthogonal path that does not cross the obstacle interior + AssertEndpoints(path, source, target); + AssertAllSegmentsOrthogonal(path); + AssertNoSegmentCrossesObstacle(path, obstacles); + } + + /// + /// With multiple staggered obstacles, the route remains orthogonal and obstacle-free. + /// + [Fact] + public void Route_MultipleObstacles_RemainsValid() + { + // Arrange: several obstacles forming a partial maze between source and target + var source = new Point2D(0, 0); + var target = new Point2D(300, 200); + var obstacles = new[] + { + new Rect(60, -20, 40, 160), + new Rect(160, 60, 40, 200), + new Rect(220, 0, 40, 120), + }; + + // Act + var path = OrthogonalEdgeRouter.Route(source, target, obstacles, clearance: 12); + + // Assert + AssertEndpoints(path, source, target); + AssertAllSegmentsOrthogonal(path); + AssertNoSegmentCrossesObstacle(path, obstacles); + } + + /// + /// Horizontally aligned endpoints with no obstacle produce a single straight segment. + /// + [Fact] + public void Route_AlignedEndpoints_ProducesStraightLine() + { + // Act: source and target share a Y coordinate with no obstacles + var path = OrthogonalEdgeRouter.Route(new Point2D(0, 30), new Point2D(150, 30), [], clearance: 10); + + // Assert: a simple two-point straight segment + Assert.Equal(2, path.Count); + AssertEndpoints(path, new Point2D(0, 30), new Point2D(150, 30)); + } + + /// + /// When a source side is given, the route leaves the source with a perpendicular stub: the + /// first segment runs in the side's outward direction. + /// + [Fact] + public void Route_WithSourceSide_LeavesPerpendicular() + { + // Arrange: source on the right side of its box, target up and to the right + var source = new Point2D(100, 100); + var target = new Point2D(200, 20); + + // Act: the source anchor is on the Right side, so the first move must go right (+x) + var path = OrthogonalEdgeRouter.Route(source, target, [], clearance: 10, sourceSide: PortSide.Right); + + // Assert: first segment is horizontal and heads to the right (outward from the Right side) + Assert.True(path.Count >= 2); + Assert.Equal(source.X, path[0].X, 6); + Assert.Equal(source.Y, path[0].Y, 6); + Assert.Equal(path[0].Y, path[1].Y, 6); // horizontal first segment + Assert.True(path[1].X > path[0].X, "First segment should leave the Right side going right."); + AssertAllSegmentsOrthogonal(path); + } + + /// + /// When a target side is given, the route enters the target with a perpendicular stub: the + /// last segment runs into the side's inward direction. + /// + [Fact] + public void Route_WithTargetSide_EntersPerpendicular() + { + // Arrange: target on the top side of its box, source below-left + var source = new Point2D(20, 200); + var target = new Point2D(150, 100); + + // Act: the target anchor is on the Top side, so the last move must arrive going down (+y) + var path = OrthogonalEdgeRouter.Route(source, target, [], clearance: 10, targetSide: PortSide.Top); + + // Assert: last segment is vertical and arrives from above (entering the Top side) + Assert.Equal(target.X, path[^1].X, 6); + Assert.Equal(target.Y, path[^1].Y, 6); + Assert.Equal(path[^1].X, path[^2].X, 6); // vertical last segment + Assert.True(path[^2].Y < path[^1].Y, "Last segment should enter the Top side from above."); + AssertAllSegmentsOrthogonal(path); + } + + /// + /// A clean route (no blocking obstacle) reports it did not cross via RouteWithStatus. + /// + [Fact] + public void RouteWithStatus_NoBlockingObstacle_ReportsNotCrossed() + { + // Act: route around a single obstacle that a channel exists past + var result = OrthogonalEdgeRouter.RouteWithStatus( + new Point2D(0, 50), new Point2D(200, 50), [new Rect(80, 0, 40, 100)], clearance: 10); + + // Assert: a valid orthogonal route was found, so Crossed is false + Assert.False(result.Crossed); + AssertAllSegmentsOrthogonal(result.Waypoints); + } + + /// + /// An obstacle squarely between the endpoints is routed around (not crossed), demonstrating + /// the clearance-retry robustness. + /// + [Fact] + public void RouteWithStatus_ObstacleBetween_RoutesAroundWithoutCrossing() + { + // Arrange: an obstacle blocking the straight path but with room to route around + var obstacles = new[] { new Rect(40, 40, 40, 40) }; + + // Act + var result = OrthogonalEdgeRouter.RouteWithStatus( + new Point2D(0, 60), new Point2D(120, 60), obstacles, clearance: 8); + + // Assert: routed cleanly (no crossing) and no segment passes through the obstacle interior + Assert.False(result.Crossed); + AssertNoSegmentCrossesObstacle(result.Waypoints, obstacles); + } + + /// + /// When the target lies inside an obstacle (no obstacle-free approach exists), the router + /// reports that it had to cross. + /// + [Fact] + public void RouteWithStatus_TargetEnclosedByObstacle_ReportsCrossed() + { + // Arrange: an obstacle that fully encloses the target point + var obstacles = new[] { new Rect(50, 0, 200, 100) }; + + // Act: target (100, 50) is strictly inside the obstacle + var result = OrthogonalEdgeRouter.RouteWithStatus( + new Point2D(0, 50), new Point2D(100, 50), obstacles, clearance: 10); + + // Assert: no clean path exists, so Crossed is reported true + Assert.True(result.Crossed); + } + + /// + /// A highway cost band makes one detour cheaper, so the router prefers routing through the band + /// rather than the equal-length alternative on the opposite side of the obstacle. + /// + [Fact] + public void RouteWithStatus_HighwayBand_PrefersBandedDetour() + { + // Arrange: an obstacle blocks the straight line; a cheaper band lies on the +y (downward) side + var source = new Point2D(0, 0); + var target = new Point2D(200, 0); + var obstacles = new[] { new Rect(80, -30, 40, 60) }; + var bands = new[] { new CostBand(IsHorizontal: true, Start: 40, End: 80, Multiplier: 0.6) }; + + // Act: route with the discounted band biasing the detour + var result = OrthogonalEdgeRouter.RouteWithStatus(source, target, obstacles, clearance: 10, costBands: bands); + + // Assert: the route dips downward into the cheaper band instead of detouring up + Assert.False(result.Crossed); + Assert.Contains(result.Waypoints, p => p.Y > 0); + } + + /// + /// A clean route keeps the requested clearance from obstacles it passes, rather than grazing + /// their edges. + /// + [Fact] + public void RouteWithStatus_CleanRoute_KeepsClearanceFromObstacles() + { + // Arrange: source sits just to the right of an obstacle; a straight drop would graze it. + var obstacle = new Rect(0, 40, 60, 80); + var obstacles = new[] { obstacle }; + + // Act: route from above-right of the obstacle to below it. + var result = OrthogonalEdgeRouter.RouteWithStatus( + new Point2D(62, 0), new Point2D(30, 200), obstacles, clearance: 10); + + // Assert: routed cleanly and every segment stays at least (nearly) the clearance away. + Assert.False(result.Crossed); + for (var i = 0; i < result.Waypoints.Count - 1; i++) + { + Assert.True( + SegmentDistanceToRect(result.Waypoints[i], result.Waypoints[i + 1], obstacle) > 10.0 - 1e-6, + $"Segment {i} runs closer than the clearance to the obstacle."); + } + } + + /// + /// A null source anchor is rejected. + /// + [Fact] + public void RouteWithStatus_NullSource_Throws() + { + // Act / Assert: a null source is rejected before routing + Assert.Throws( + () => OrthogonalEdgeRouter.RouteWithStatus(null!, new Point2D(100, 80), [], clearance: 10)); + } + + /// + /// A null target anchor is rejected. + /// + [Fact] + public void RouteWithStatus_NullTarget_Throws() + { + // Act / Assert: a null target is rejected before routing + Assert.Throws( + () => OrthogonalEdgeRouter.RouteWithStatus(new Point2D(0, 0), null!, [], clearance: 10)); + } + + /// + /// A null obstacle list is rejected. + /// + [Fact] + public void RouteWithStatus_NullObstacles_Throws() + { + // Act / Assert: a null obstacle list is rejected before routing + Assert.Throws( + () => OrthogonalEdgeRouter.RouteWithStatus(new Point2D(0, 0), new Point2D(100, 80), null!, clearance: 10)); + } + + /// + /// Asserts that the path begins at the expected source and ends at the expected target. + /// + private static void AssertEndpoints(IReadOnlyList path, Point2D source, Point2D target) + { + Assert.True(path.Count >= 2); + Assert.Equal(source.X, path[0].X, 6); + Assert.Equal(source.Y, path[0].Y, 6); + Assert.Equal(target.X, path[^1].X, 6); + Assert.Equal(target.Y, path[^1].Y, 6); + } + + /// + /// Asserts that every consecutive pair of waypoints forms a horizontal or vertical segment. + /// + private static void AssertAllSegmentsOrthogonal(IReadOnlyList path) + { + for (var i = 0; i < path.Count - 1; i++) + { + var a = path[i]; + var b = path[i + 1]; + var horizontal = Math.Abs(a.Y - b.Y) < 1e-6; + var vertical = Math.Abs(a.X - b.X) < 1e-6; + Assert.True(horizontal || vertical, + $"Segment {i} from ({a.X},{a.Y}) to ({b.X},{b.Y}) is not orthogonal."); + } + } + + /// + /// Asserts that no segment of the path passes through the interior of any obstacle. + /// + private static void AssertNoSegmentCrossesObstacle(IReadOnlyList path, IReadOnlyList obstacles) + { + for (var i = 0; i < path.Count - 1; i++) + { + var a = path[i]; + var b = path[i + 1]; + foreach (var r in obstacles) + { + Assert.False(SegmentCrossesRect(a, b, r), + $"Segment {i} from ({a.X},{a.Y}) to ({b.X},{b.Y}) crosses obstacle."); + } + } + } + + /// + /// Returns true when the axis-aligned segment passes through the strict interior of the rect. + /// + private static bool SegmentCrossesRect(Point2D a, Point2D b, Rect r) + { + if (Math.Abs(a.Y - b.Y) < 1e-6) + { + // Horizontal segment + var y = a.Y; + var xa = Math.Min(a.X, b.X); + var xb = Math.Max(a.X, b.X); + return r.Y < y && y < r.Y + r.Height && + Math.Max(xa, r.X) < Math.Min(xb, r.X + r.Width); + } + + // Vertical segment + var x = a.X; + var ya = Math.Min(a.Y, b.Y); + var yb = Math.Max(a.Y, b.Y); + return r.X < x && x < r.X + r.Width && + Math.Max(ya, r.Y) < Math.Min(yb, r.Y + r.Height); + } + + /// + /// Returns the Euclidean distance from an axis-aligned segment to an axis-aligned rectangle + /// (0 when they intersect). + /// + private static double SegmentDistanceToRect(Point2D a, Point2D b, Rect r) + { + var xlo = Math.Min(a.X, b.X); + var xhi = Math.Max(a.X, b.X); + var ylo = Math.Min(a.Y, b.Y); + var yhi = Math.Max(a.Y, b.Y); + + var dx = Math.Max(0.0, Math.Max(r.X - xhi, xlo - (r.X + r.Width))); + var dy = Math.Max(0.0, Math.Max(r.Y - yhi, ylo - (r.Y + r.Height))); + return Math.Sqrt((dx * dx) + (dy * dy)); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs new file mode 100644 index 0000000..16479f8 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs @@ -0,0 +1,575 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Layout.Tests; + +/// +/// Tests for the bundled , exercising the recursive +/// compound-graph path (per-scope algorithm selection, container sizing, absolute composition, and +/// cross-container edge routing) and — critically — the flat-graph equivalence guarantee that a +/// graph with no container nodes produces output byte-for-byte identical to the selected leaf +/// algorithm applied directly. +/// +public sealed class HierarchicalLayoutAlgorithmTests +{ + /// + /// Proves that the algorithm advertises the stable "hierarchical" identifier. + /// + [Fact] + public void Id_IsHierarchical() + { + // Act / Assert + Assert.Equal("hierarchical", new HierarchicalLayoutAlgorithm().Id); + } + + /// + /// Proves the flat-graph equivalence guarantee for the layered leaf algorithm: across many + /// pseudo-randomly generated flat graphs, the hierarchical engine returns exactly what the + /// layered algorithm returns, bit-for-bit. + /// + [Fact] + public void Apply_FlatRandomGraphs_MatchLayeredAlgorithmExactly() + { + for (var seed = 0; seed < 400; seed++) + { + // Arrange: an identical flat graph and options for both algorithms + var graph = BuildRandomFlatGraph(seed); + var options = LayoutOptions.ForAlgorithm("layered"); + + // Act + var expected = new LayeredLayoutAlgorithm().Apply(graph, options); + var actual = new HierarchicalLayoutAlgorithm().Apply(graph, options); + + // Assert + AssertTreesIdentical($"layered seed {seed}", expected, actual); + } + } + + /// + /// Proves the flat-graph equivalence guarantee for the containment leaf algorithm: a flat graph + /// run through the hierarchical engine with the containment algorithm selected is identical to + /// the containment algorithm applied directly. + /// + [Fact] + public void Apply_FlatRandomGraphs_MatchContainmentAlgorithmExactly() + { + for (var seed = 0; seed < 150; seed++) + { + // Arrange + var graph = BuildRandomFlatGraph(seed); + var options = LayoutOptions.ForAlgorithm("containment"); + + // Act + var expected = new ContainmentLayoutAlgorithm().Apply(graph, options); + var actual = new HierarchicalLayoutAlgorithm().Apply(graph, options); + + // Assert + AssertTreesIdentical($"containment seed {seed}", expected, actual); + } + } + + /// + /// Proves that a container node is sized to enclose its children and its recursively laid-out + /// children are nested at absolute coordinates entirely within the container's bounds. + /// + [Fact] + public void Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely() + { + // Arrange: a labelled container holding three leaves and an intra edge, plus a sibling leaf + var graph = new LayoutGraph(); + var group = graph.AddNode("group", 10, 10); + group.Label = "Group"; + var c1 = group.Children.AddNode("c1", 80, 40); + var c2 = group.Children.AddNode("c2", 80, 40); + var c3 = group.Children.AddNode("c3", 80, 40); + group.Children.AddEdge("c1-c2", c1, c2); + group.Children.AddEdge("c2-c3", c2, c3); + graph.AddNode("outside", 80, 40); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: the container box is present and carries nested boxes + var boxes = tree.Nodes.OfType().ToList(); + Assert.Equal(2, boxes.Count); + var containerBox = Assert.Single(boxes, box => box.Children.Count > 0); + var nestedBoxes = containerBox.Children.OfType().ToList(); + Assert.Equal(3, nestedBoxes.Count); + + // Every nested box lies fully within the container's bounds. + foreach (var nested in nestedBoxes) + { + Assert.True(nested.X >= containerBox.X - 1e-9); + Assert.True(nested.Y >= containerBox.Y - 1e-9); + Assert.True(nested.X + nested.Width <= containerBox.X + containerBox.Width + 1e-9); + Assert.True(nested.Y + nested.Height <= containerBox.Y + containerBox.Height + 1e-9); + } + + // The reported canvas encloses every top-level box. + foreach (var box in boxes) + { + Assert.True(box.X + box.Width <= tree.Width + 1e-9); + Assert.True(box.Y + box.Height <= tree.Height + 1e-9); + } + } + + /// + /// Proves that a containment-packed root can hold a container whose children are laid out with a + /// per-node layered override, composing without error. + /// + [Fact] + public void Apply_ContainmentRootWithLayeredContainer_Composes() + { + // Arrange: a containment root with a container that overrides its algorithm to "layered" + var graph = new LayoutGraph(); + var group = graph.AddNode("group", 10, 10); + group.Set(CoreOptions.Algorithm, "layered"); + var a = group.Children.AddNode("a", 80, 40); + var b = group.Children.AddNode("b", 80, 40); + var c = group.Children.AddNode("c", 80, 40); + group.Children.AddEdge("a-b", a, b); + group.Children.AddEdge("b-c", b, c); + graph.AddNode("peer", 80, 40); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment")); + + // Assert: the container was composed with nested children + var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0); + Assert.Equal(3, containerBox.Children.OfType().Count()); + } + + /// + /// Proves the reverse composition: a layered root holding a container whose children are packed + /// with a per-node containment override. + /// + [Fact] + public void Apply_LayeredRootWithContainmentContainer_Composes() + { + // Arrange: a layered root with a container that overrides its algorithm to "containment" + var graph = new LayoutGraph(); + var group = graph.AddNode("group", 10, 10); + group.Set(CoreOptions.Algorithm, "containment"); + var a = group.Children.AddNode("a", 80, 40); + var b = group.Children.AddNode("b", 80, 40); + var c = group.Children.AddNode("c", 80, 40); + group.Children.AddEdge("a-b", a, b); + group.Children.AddEdge("b-c", b, c); + graph.AddNode("peer", 80, 40); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert + var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0); + Assert.Equal(3, containerBox.Children.OfType().Count()); + } + + /// + /// Proves that a cross-container edge — one whose endpoints live inside different sibling + /// containers — is routed at the owning scope around the intervening container rather than + /// through its interior. + /// + [Fact] + public void Apply_CrossContainerEdge_RoutesAroundInterveningContainer() + { + // Arrange: three sibling containers packed by a containment root, with a cross-container edge + // from a child of the first to a child of the third added at the root (their LCA). + var graph = new LayoutGraph(); + var a = graph.AddNode("A", 10, 10); + var mid = graph.AddNode("MID", 10, 10); + var b = graph.AddNode("B", 10, 10); + var aChild = a.Children.AddNode("a-child", 60, 40); + mid.Children.AddNode("mid-child", 60, 40); + var bChild = b.Children.AddNode("b-child", 60, 40); + graph.AddEdge("cross", aChild, bChild); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment")); + + // Assert: exactly one routed line exists at the root (the cross-container edge) + var line = Assert.Single(tree.Nodes.OfType()); + Assert.True(line.Waypoints.Count >= 2); + + // The MID container box is the second placed box (nodes are emitted in input order). + var boxes = tree.Nodes.OfType().ToList(); + Assert.Equal(3, boxes.Count); + var midBox = boxes[1]; + var obstacle = new Rect(midBox.X, midBox.Y, midBox.Width, midBox.Height); + + // No routed segment passes through the intervening container's interior. + for (var i = 0; i < line.Waypoints.Count - 1; i++) + { + Assert.False( + SegmentCrossesRect(line.Waypoints[i], line.Waypoints[i + 1], obstacle), + $"Segment {i} crosses the intervening container box."); + } + } + + /// + /// Proves that a cross-container edge to a WIDE container placed above a small sibling does not + /// wrap back across an endpoint box. Regression: anchoring a connector by the direction to the + /// other box's centre chose the wrong side for wide boxes (whose centre sits far past the near + /// edge), forcing the route to double back across its own source box. + /// + [Fact] + public void Apply_CrossContainerEdge_ToWideContainer_DoesNotCrossEndpointBoxes() + { + // Arrange: a wide container (three children laid out in a row) plus a small sibling leaf, joined + // by a cross-container edge from the leaf to a child inside the container. + var graph = new LayoutGraph(); + var service = graph.AddNode("service", 10, 10); + var api = service.Children.AddNode("api", 120, 50); + var worker = service.Children.AddNode("worker", 120, 50); + var store = service.Children.AddNode("store", 120, 50); + service.Children.AddEdge("api-worker", api, worker); + service.Children.AddEdge("worker-store", worker, store); + var client = graph.AddNode("client", 120, 50); + graph.AddEdge("client-api", client, api); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + // Assert: the single top-level cross-container line does not pass through the interior of either + // endpoint container box (the wide container or the leaf). + var line = Assert.Single(tree.Nodes.OfType()); + foreach (var box in tree.Nodes.OfType()) + { + var rect = new Rect(box.X, box.Y, box.Width, box.Height); + for (var i = 0; i < line.Waypoints.Count - 1; i++) + { + Assert.False( + SegmentCrossesRect(line.Waypoints[i], line.Waypoints[i + 1], rect), + $"Cross-container segment {i} crosses the '{box.Label}' box interior."); + } + } + } + + /// + /// Proves that three levels of nesting compose so a box contains a box that contains a box. + /// + [Fact] + public void Apply_ThreeLevelNesting_Succeeds() + { + // Arrange: root -> container A -> container B -> leaf + var graph = new LayoutGraph(); + var a = graph.AddNode("A", 10, 10); + var b = a.Children.AddNode("B", 10, 10); + b.Children.AddNode("leaf", 80, 40); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: the nesting depth is reflected in the composed box tree + var outer = Assert.Single(tree.Nodes.OfType()); + var middle = Assert.Single(outer.Children.OfType()); + var inner = Assert.Single(middle.Children.OfType()); + Assert.Empty(inner.Children); + } + + /// + /// Proves that the engine never mutates the caller's input graph: after laying out a compound + /// graph, every original node — the container placeholder and its leaf children — retains the + /// exact width and height it was given, because effective container sizing is done over an + /// internal sized view rather than by writing back to the caller's nodes. + /// + [Fact] + public void Apply_CompoundGraph_DoesNotMutateInputNodeSizes() + { + // Arrange: a labelled container (small placeholder size) holding two differently sized leaves + var graph = new LayoutGraph(); + var group = graph.AddNode("group", 10, 10); + group.Label = "Group"; + var c1 = group.Children.AddNode("c1", 80, 40); + var c2 = group.Children.AddNode("c2", 123, 57); + group.Children.AddEdge("c1-c2", c1, c2); + var outside = graph.AddNode("outside", 64, 48); + + // Act + _ = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: every original node keeps its exact input dimensions (bit-for-bit) + AssertExact("group.Width", 10, group.Width); + AssertExact("group.Height", 10, group.Height); + AssertExact("c1.Width", 80, c1.Width); + AssertExact("c1.Height", 40, c1.Height); + AssertExact("c2.Width", 123, c2.Width); + AssertExact("c2.Height", 57, c2.Height); + AssertExact("outside.Width", 64, outside.Width); + AssertExact("outside.Height", 48, outside.Height); + } + + /// + /// Proves that a flat empty graph is delegated to the layered algorithm, yielding an empty + /// placed tree with a positive-size canvas. + /// + [Fact] + public void Apply_EmptyGraph_ReturnsEmptyCanvas() + { + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(new LayoutGraph(), LayoutOptions.ForAlgorithm("layered")); + + // Assert + Assert.Empty(tree.Nodes); + Assert.True(tree.Width > 0); + Assert.True(tree.Height > 0); + } + + /// + /// Proves that a node whose child graph was materialized but never populated is treated as a + /// leaf, keeping the whole graph on the flat fast path. + /// + [Fact] + public void Apply_ContainerWithEmptyChildren_TreatedAsLeaf() + { + // Arrange: a graph with a single node whose empty child graph has been materialized + var graph = new LayoutGraph(); + var node = graph.AddNode("n", 80, 40); + _ = node.Children; // materialize but do not populate + Assert.False(node.HasChildren); + var options = LayoutOptions.ForAlgorithm("layered"); + + // Act + var expected = new LayeredLayoutAlgorithm().Apply(graph, options); + var actual = new HierarchicalLayoutAlgorithm().Apply(graph, options); + + // Assert: identical to the layered algorithm, and the single box carries no children + AssertTreesIdentical("empty-children", expected, actual); + var box = Assert.Single(actual.Nodes.OfType()); + Assert.Empty(box.Children); + } + + /// + /// Proves that a null graph argument is rejected. + /// + [Fact] + public void Apply_NullGraph_Throws() + { + // Act / Assert + Assert.Throws( + () => new HierarchicalLayoutAlgorithm().Apply(null!, new LayoutOptions())); + } + + /// + /// Proves that a null options argument is rejected. + /// + [Fact] + public void Apply_NullOptions_Throws() + { + // Act / Assert + Assert.Throws( + () => new HierarchicalLayoutAlgorithm().Apply(new LayoutGraph(), null!)); + } + + /// + /// Proves that a null registry argument is rejected by the injecting constructor. + /// + [Fact] + public void Constructor_NullRegistry_Throws() + { + // Act / Assert + Assert.Throws(() => new HierarchicalLayoutAlgorithm(null!)); + } + + /// + /// Proves that naming this engine's own "hierarchical" identifier as the scope algorithm — the + /// documented default id — does not fail to resolve, but degrades to the default leaf algorithm + /// (layered) and yields output identical to the default (unset) path. + /// + [Fact] + public void Apply_ExplicitHierarchicalOptions_MatchesDefaultLeafExactly() + { + for (var seed = 0; seed < 50; seed++) + { + // Arrange: the same flat graph selected explicitly as "hierarchical" and (implicitly) layered + var graph = BuildRandomFlatGraph(seed); + + // Act + var explicitHierarchical = new HierarchicalLayoutAlgorithm() + .Apply(graph, LayoutOptions.ForAlgorithm(HierarchicalLayoutAlgorithm.AlgorithmId)); + var layered = new LayeredLayoutAlgorithm() + .Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: explicit "hierarchical" resolves to the layered leaf, bit-for-bit + AssertTreesIdentical($"explicit-hierarchical seed {seed}", layered, explicitHierarchical); + } + } + + /// + /// Proves that a container node explicitly set to the "hierarchical" algorithm composes without a + /// resolution failure, placing that scope with the default leaf algorithm. + /// + [Fact] + public void Apply_ContainerNodeSetHierarchical_Composes() + { + // Arrange: a layered root holding a container that overrides its algorithm to "hierarchical" + var graph = new LayoutGraph(); + var group = graph.AddNode("group", 10, 10); + group.Set(CoreOptions.Algorithm, HierarchicalLayoutAlgorithm.AlgorithmId); + var a = group.Children.AddNode("a", 80, 40); + var b = group.Children.AddNode("b", 80, 40); + group.Children.AddEdge("a-b", a, b); + graph.AddNode("peer", 80, 40); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: the container was composed with its two nested children + var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0); + Assert.Equal(2, containerBox.Children.OfType().Count()); + } + + /// + /// Proves that a graph whose root carries an explicit "hierarchical" algorithm override lays out + /// without a resolution failure. + /// + [Fact] + public void Apply_GraphSetHierarchical_DoesNotThrow() + { + // Arrange: a compound graph whose root explicitly selects "hierarchical" + var graph = new LayoutGraph(); + graph.Set(CoreOptions.Algorithm, HierarchicalLayoutAlgorithm.AlgorithmId); + var group = graph.AddNode("group", 10, 10); + group.Children.AddNode("child", 80, 40); + graph.AddNode("peer", 80, 40); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + // Assert: the container was composed with its nested child + var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0); + Assert.Single(containerBox.Children.OfType()); + } + + /// + /// Deterministically builds a flat (non-nested) layout graph for the given seed, with random + /// node sizes and arbitrary edges (including self-loops, parallel edges, and cycles). + /// + private static LayoutGraph BuildRandomFlatGraph(int seed) + { + var rng = new Random(seed); + var graph = new LayoutGraph(); + + var count = rng.Next(0, 16); + var nodes = new LayoutGraphNode[count]; + for (var i = 0; i < count; i++) + { + var node = graph.AddNode($"n{i}", rng.Next(40, 240), rng.Next(30, 120)); + + // Give some nodes labels so the equivalence check also covers label propagation. + if (rng.Next(2) == 0) + { + node.Label = $"N{i}"; + } + + nodes[i] = node; + } + + if (count > 0) + { + var edgeCount = rng.Next(0, (count * 2) + 1); + for (var e = 0; e < edgeCount; e++) + { + var source = nodes[rng.Next(count)]; + var target = nodes[rng.Next(count)]; + graph.AddEdge($"e{e}", source, target); + } + } + + return graph; + } + + /// + /// Deep-compares two layout trees for exact (bit-level) equality of every geometric field, + /// node kind, box attribute, and line attribute. No numeric tolerance is allowed. + /// + private static void AssertTreesIdentical(string context, LayoutTree expected, LayoutTree actual) + { + AssertExact($"{context}: Width", expected.Width, actual.Width); + AssertExact($"{context}: Height", expected.Height, actual.Height); + + Assert.Equal(expected.Nodes.Count, actual.Nodes.Count); + for (var i = 0; i < expected.Nodes.Count; i++) + { + var expectedNode = expected.Nodes[i]; + var actualNode = actual.Nodes[i]; + Assert.Equal(expectedNode.GetType(), actualNode.GetType()); + + switch (expectedNode) + { + case LayoutBox expectedBox: + AssertBoxesIdentical($"{context}: Nodes[{i}]", expectedBox, (LayoutBox)actualNode); + break; + case LayoutLine expectedLine: + AssertLinesIdentical($"{context}: Nodes[{i}]", expectedLine, (LayoutLine)actualNode); + break; + default: + Assert.Fail($"{context}: Nodes[{i}] has an unexpected node type {expectedNode.GetType()}."); + break; + } + } + } + + /// Deep-compares two boxes for exact equality of every geometric and display field. + private static void AssertBoxesIdentical(string context, LayoutBox expected, LayoutBox actual) + { + AssertExact($"{context}.X", expected.X, actual.X); + AssertExact($"{context}.Y", expected.Y, actual.Y); + AssertExact($"{context}.Width", expected.Width, actual.Width); + AssertExact($"{context}.Height", expected.Height, actual.Height); + Assert.Equal(expected.Label, actual.Label); + Assert.Equal(expected.Depth, actual.Depth); + Assert.Equal(expected.Shape, actual.Shape); + Assert.Equal(expected.Children.Count, actual.Children.Count); + } + + /// Deep-compares two lines for exact equality of waypoints and styling. + private static void AssertLinesIdentical(string context, LayoutLine expected, LayoutLine actual) + { + Assert.Equal(expected.Waypoints.Count, actual.Waypoints.Count); + for (var w = 0; w < expected.Waypoints.Count; w++) + { + AssertExact($"{context}.Waypoints[{w}].X", expected.Waypoints[w].X, actual.Waypoints[w].X); + AssertExact($"{context}.Waypoints[{w}].Y", expected.Waypoints[w].Y, actual.Waypoints[w].Y); + } + + Assert.Equal(expected.SourceEnd, actual.SourceEnd); + Assert.Equal(expected.TargetEnd, actual.TargetEnd); + Assert.Equal(expected.LineStyle, actual.LineStyle); + Assert.Equal(expected.MidpointLabel, actual.MidpointLabel); + } + + /// Asserts that two doubles are identical at the bit level (no tolerance). + private static void AssertExact(string context, double expected, double actual) + { + Assert.True( + BitConverter.DoubleToInt64Bits(expected) == BitConverter.DoubleToInt64Bits(actual), + $"{context}: expected {expected:R} but got {actual:R}"); + } + + /// + /// Returns true when the axis-aligned segment passes through the strict interior of the rect. + /// + private static bool SegmentCrossesRect(Point2D a, Point2D b, Rect r) + { + if (Math.Abs(a.Y - b.Y) < 1e-6) + { + // Horizontal segment + var y = a.Y; + var xa = Math.Min(a.X, b.X); + var xb = Math.Max(a.X, b.X); + return r.Y < y && y < r.Y + r.Height && + Math.Max(xa, r.X) < Math.Min(xb, r.X + r.Width); + } + + // Vertical segment + var x = a.X; + var ya = Math.Min(a.Y, b.Y); + var yb = Math.Max(a.Y, b.Y); + return r.X < x && x < r.X + r.Width && + Math.Max(ya, r.Y) < Math.Min(yb, r.Y + r.Height); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs new file mode 100644 index 0000000..ed9eb26 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs @@ -0,0 +1,108 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Layout.Tests; + +/// +/// Tests for the bundled , exercising the full +/// graph-to-placed-tree path. +/// +public class LayeredLayoutAlgorithmTests +{ + /// + /// Proves that the algorithm advertises the stable "layered" identifier. + /// + [Fact] + public void Id_IsLayered() + { + Assert.Equal("layered", new LayeredLayoutAlgorithm().Id); + } + + /// + /// Proves that a simple chain graph is placed into left-to-right layers with one box per node + /// and one routed connector per edge. + /// + [Fact] + public void Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges() + { + 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); + graph.AddEdge("e1", a, b); + graph.AddEdge("e2", b, c); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var boxes = tree.Nodes.OfType().ToList(); + var lines = tree.Nodes.OfType().ToList(); + + Assert.Equal(3, boxes.Count); + Assert.Equal(2, lines.Count); + Assert.True(tree.Width > 0); + Assert.True(tree.Height > 0); + + // Boxes are emitted in input order; a chain lays out with strictly increasing X. + Assert.True(boxes[0].X < boxes[1].X); + Assert.True(boxes[1].X < boxes[2].X); + + // Every connector has at least a start and an end waypoint. + Assert.All(lines, line => Assert.True(line.Waypoints.Count >= 2)); + } + + /// + /// Proves that an empty graph produces an empty, positively-sized canvas. + /// + [Fact] + public void Apply_EmptyGraph_ReturnsEmptyCanvas() + { + var tree = new LayeredLayoutAlgorithm().Apply(new LayoutGraph(), new LayoutOptions()); + + Assert.Empty(tree.Nodes); + Assert.True(tree.Width > 0); + Assert.True(tree.Height > 0); + } + + /// + /// Proves that a null graph argument is rejected. + /// + [Fact] + public void Apply_NullGraph_Throws() + { + Assert.Throws( + () => new LayeredLayoutAlgorithm().Apply(null!, new LayoutOptions())); + } + + /// + /// Proves that the hierarchical input-model capability is behavior-preserving: a flat graph + /// whose nodes declare no children lays out exactly as before, because the layered algorithm + /// reads only the top-level nodes and edges and ignores nesting. + /// + [Fact] + public void Apply_FlatGraphWithNoChildren_PlacesTopLevelStructureUnchanged() + { + // Arrange: build a flat chain graph and confirm none of its nodes are containers + 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); + graph.AddEdge("e1", a, b); + graph.AddEdge("e2", b, c); + Assert.All(new[] { a, b, c }, node => Assert.False(node.HasChildren)); + + // Act: lay the flat graph out with the bundled algorithm + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + // Assert: the placed structure matches the classic flat result (one box per node, + // one connector per edge, left-to-right chain ordering) + var boxes = tree.Nodes.OfType().ToList(); + var lines = tree.Nodes.OfType().ToList(); + Assert.Equal(3, boxes.Count); + Assert.Equal(2, lines.Count); + Assert.True(boxes[0].X < boxes[1].X); + Assert.True(boxes[1].X < boxes[2].X); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs new file mode 100644 index 0000000..6d57bed --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayoutAlgorithmsTests.cs @@ -0,0 +1,108 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Layout.Tests; + +/// +/// Tests for the default-registry factory, proving it registers each +/// bundled algorithm under its stable identifier and returns an independently mutable instance on +/// every call. +/// +public sealed class LayoutAlgorithmsTests +{ + /// + /// Proves the default registry resolves the bundled layered algorithm by its identifier. + /// + [Fact] + public void CreateDefaultRegistry_ResolvesLayeredAlgorithm() + { + // Arrange + var registry = LayoutAlgorithms.CreateDefaultRegistry(); + + // Act + var algorithm = registry.Resolve("layered"); + + // Assert + Assert.IsType(algorithm); + } + + /// + /// Proves the default registry resolves the bundled containment algorithm by its identifier. + /// + [Fact] + public void CreateDefaultRegistry_ResolvesContainmentAlgorithm() + { + // Arrange + var registry = LayoutAlgorithms.CreateDefaultRegistry(); + + // Act + var algorithm = registry.Resolve("containment"); + + // Assert + Assert.IsType(algorithm); + } + + /// + /// Proves the default registry resolves the bundled hierarchical algorithm by its identifier. + /// + [Fact] + public void CreateDefaultRegistry_ResolvesHierarchicalAlgorithm() + { + // Arrange + var registry = LayoutAlgorithms.CreateDefaultRegistry(); + + // Act + var algorithm = registry.Resolve("hierarchical"); + + // Assert + Assert.IsType(algorithm); + } + + /// + /// Proves the default registry contains exactly the three bundled algorithm identifiers. + /// + [Fact] + public void CreateDefaultRegistry_RegistersOnlyTheThreeBundledAlgorithms() + { + // Arrange + var registry = LayoutAlgorithms.CreateDefaultRegistry(); + + // Act + var ids = registry.Ids.OrderBy(id => id, StringComparer.Ordinal).ToArray(); + + // Assert + Assert.Equal(["containment", "hierarchical", "layered"], ids); + } + + /// + /// Proves each call returns an independent registry, so registering into one does not affect a + /// registry from a separate call. + /// + [Fact] + public void CreateDefaultRegistry_ReturnsIndependentInstances() + { + // Arrange + var first = LayoutAlgorithms.CreateDefaultRegistry(); + var second = LayoutAlgorithms.CreateDefaultRegistry(); + + // Act: register a stub only in the first registry + first.Register(new StubAlgorithm()); + + // Assert: the second registry is unaffected + Assert.True(first.Contains("stub")); + Assert.False(second.Contains("stub")); + } + + /// A minimal stub used to prove registry independence. + private sealed class StubAlgorithm : ILayoutAlgorithm + { + public string Id => "stub"; + + public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) => + new(1, 1, []); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs new file mode 100644 index 0000000..8eb7285 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayoutEngineTests.cs @@ -0,0 +1,281 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Layout.Tests; + +/// +/// Tests for the batteries-included facade, covering algorithm +/// resolution (graph precedence over options, default to hierarchical), the flat-graph equivalence +/// to the corresponding leaf algorithm, nested-graph composition, custom-registry support, and +/// null-argument validation. +/// +public sealed class LayoutEngineTests +{ + /// + /// Proves the facade's declared default algorithm identifier is "hierarchical". + /// + [Fact] + public void DefaultAlgorithmId_IsHierarchical() + { + // Act / Assert + Assert.Equal("hierarchical", LayoutEngine.DefaultAlgorithmId); + } + + /// + /// Proves that laying out a flat graph with no declared algorithm produces output identical to + /// the layered leaf algorithm, because the default hierarchical engine is byte-for-byte + /// equivalent to its leaf algorithm on a flat graph. + /// + [Fact] + public void Layout_FlatGraphNoAlgorithmDeclared_MatchesLayeredLeafExactly() + { + // Arrange + var graph = BuildFlatGraph(); + var options = new LayoutOptions(); + + // Act + var expected = new LayeredLayoutAlgorithm().Apply(graph, options); + var actual = LayoutEngine.Layout(graph, options); + + // Assert + AssertTreesIdentical(expected, actual); + } + + /// + /// Proves that an explicit "layered" declaration on the options resolves the layered algorithm, + /// producing output identical to invoking that algorithm directly. + /// + [Fact] + public void Layout_OptionsDeclareLayered_MatchesLayeredAlgorithmExactly() + { + // Arrange + var graph = BuildFlatGraph(); + var options = LayoutOptions.ForAlgorithm("layered"); + + // Act + var expected = new LayeredLayoutAlgorithm().Apply(graph, options); + var actual = LayoutEngine.Layout(graph, options); + + // Assert + AssertTreesIdentical(expected, actual); + } + + /// + /// Proves that an explicit "containment" declaration on the options resolves the containment + /// algorithm, producing output identical to invoking that algorithm directly. + /// + [Fact] + public void Layout_OptionsDeclareContainment_MatchesContainmentAlgorithmExactly() + { + // Arrange + var graph = BuildFlatGraph(); + var options = LayoutOptions.ForAlgorithm("containment"); + + // Act + var expected = new ContainmentLayoutAlgorithm().Apply(graph, options); + var actual = LayoutEngine.Layout(graph, options); + + // Assert + AssertTreesIdentical(expected, actual); + } + + /// + /// Proves the graph's explicit algorithm declaration takes precedence over the options' + /// declaration: a graph declaring "containment" is packed even when the options declare "layered". + /// + [Fact] + public void Layout_GraphDeclarationOverridesOptions() + { + // Arrange: the graph declares containment, the options declare layered + var graph = BuildFlatGraph(); + graph.Set(CoreOptions.Algorithm, "containment"); + var options = LayoutOptions.ForAlgorithm("layered"); + + // Act + var expected = new ContainmentLayoutAlgorithm().Apply(graph, options); + var actual = LayoutEngine.Layout(graph, options); + + // Assert: the containment result wins + AssertTreesIdentical(expected, actual); + } + + /// + /// Proves that a nested (compound) graph laid out with the default hierarchical engine produces a + /// composed tree in which the container box carries its recursively laid-out children. + /// + [Fact] + public void Layout_NestedGraphNoAlgorithmDeclared_ProducesComposedTree() + { + // Arrange: a container holding two nested leaves, plus a sibling leaf at the root + var graph = new LayoutGraph(); + var group = graph.AddNode("group", 10, 10); + group.Label = "Group"; + var c1 = group.Children.AddNode("c1", 80, 40); + var c2 = group.Children.AddNode("c2", 80, 40); + group.Children.AddEdge("c1-c2", c1, c2); + graph.AddNode("outside", 80, 40); + + // Act + var tree = LayoutEngine.Layout(graph, new LayoutOptions()); + + // Assert: the container box was composed with its nested children + var containerBox = Assert.Single(tree.Nodes.OfType(), box => box.Children.Count > 0); + Assert.Equal(2, containerBox.Children.OfType().Count()); + } + + /// + /// Proves a caller-supplied registry is honored: an algorithm registered only in a custom + /// registry is resolved and applied when the graph declares its identifier. + /// + [Fact] + public void Layout_CustomRegistry_ResolvesRegisteredAlgorithm() + { + // Arrange: a registry carrying a stub algorithm selected by the graph + var registry = new LayoutAlgorithmRegistry().Register(new StubAlgorithm()); + var graph = new LayoutGraph(); + graph.Set(CoreOptions.Algorithm, "stub"); + + // Act + var tree = LayoutEngine.Layout(graph, new LayoutOptions(), registry); + + // Assert: the stub's sentinel canvas proves it was the algorithm applied + Assert.Equal(StubAlgorithm.SentinelWidth, tree.Width); + Assert.Equal(StubAlgorithm.SentinelHeight, tree.Height); + } + + /// + /// Proves an unresolvable algorithm identifier surfaces the registry's key-not-found error. + /// + [Fact] + public void Layout_UnregisteredAlgorithm_Throws() + { + // Arrange: a graph declaring an algorithm the default registry does not contain + var graph = new LayoutGraph(); + graph.Set(CoreOptions.Algorithm, "nope"); + + // Act / Assert + Assert.Throws(() => LayoutEngine.Layout(graph, new LayoutOptions())); + } + + /// + /// Proves a null graph argument is rejected. + /// + [Fact] + public void Layout_NullGraph_Throws() + { + // Act / Assert + Assert.Throws(() => LayoutEngine.Layout(null!, new LayoutOptions())); + } + + /// + /// Proves a null options argument is rejected. + /// + [Fact] + public void Layout_NullOptions_Throws() + { + // Act / Assert + Assert.Throws(() => LayoutEngine.Layout(new LayoutGraph(), null!)); + } + + /// + /// Proves a null registry argument is rejected by the custom-registry overload. + /// + [Fact] + public void Layout_NullRegistry_Throws() + { + // Act / Assert + Assert.Throws( + () => LayoutEngine.Layout(new LayoutGraph(), new LayoutOptions(), null!)); + } + + /// Builds a small deterministic flat graph of three labelled leaves and two edges. + private static LayoutGraph BuildFlatGraph() + { + 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); + return graph; + } + + /// + /// Deep-compares two layout trees for exact (bit-level) equality of canvas size, node kinds, box + /// geometry and attributes, and every line waypoint and style. + /// + private static void AssertTreesIdentical(LayoutTree expected, LayoutTree actual) + { + AssertExact(expected.Width, actual.Width); + AssertExact(expected.Height, actual.Height); + Assert.Equal(expected.Nodes.Count, actual.Nodes.Count); + + for (var i = 0; i < expected.Nodes.Count; i++) + { + var expectedNode = expected.Nodes[i]; + var actualNode = actual.Nodes[i]; + Assert.Equal(expectedNode.GetType(), actualNode.GetType()); + + switch (expectedNode) + { + case LayoutBox expectedBox: + var actualBox = (LayoutBox)actualNode; + AssertExact(expectedBox.X, actualBox.X); + AssertExact(expectedBox.Y, actualBox.Y); + AssertExact(expectedBox.Width, actualBox.Width); + AssertExact(expectedBox.Height, actualBox.Height); + Assert.Equal(expectedBox.Label, actualBox.Label); + Assert.Equal(expectedBox.Depth, actualBox.Depth); + Assert.Equal(expectedBox.Shape, actualBox.Shape); + Assert.Equal(expectedBox.Children.Count, actualBox.Children.Count); + break; + case LayoutLine expectedLine: + var actualLine = (LayoutLine)actualNode; + Assert.Equal(expectedLine.Waypoints.Count, actualLine.Waypoints.Count); + for (var w = 0; w < expectedLine.Waypoints.Count; w++) + { + AssertExact(expectedLine.Waypoints[w].X, actualLine.Waypoints[w].X); + AssertExact(expectedLine.Waypoints[w].Y, actualLine.Waypoints[w].Y); + } + + Assert.Equal(expectedLine.SourceEnd, actualLine.SourceEnd); + Assert.Equal(expectedLine.TargetEnd, actualLine.TargetEnd); + Assert.Equal(expectedLine.LineStyle, actualLine.LineStyle); + Assert.Equal(expectedLine.MidpointLabel, actualLine.MidpointLabel); + break; + default: + Assert.Fail($"Unexpected node type {expectedNode.GetType()}."); + break; + } + } + } + + /// Asserts that two doubles are identical at the bit level (no tolerance). + private static void AssertExact(double expected, double actual) => + Assert.True( + BitConverter.DoubleToInt64Bits(expected) == BitConverter.DoubleToInt64Bits(actual), + $"expected {expected:R} but got {actual:R}"); + + /// + /// A minimal stub returning a recognizable sentinel canvas so a + /// test can prove it — and not a bundled algorithm — was the one applied. + /// + private sealed class StubAlgorithm : ILayoutAlgorithm + { + public const double SentinelWidth = 4242.0; + + public const double SentinelHeight = 2424.0; + + public string Id => "stub"; + + public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) => + new(SentinelWidth, SentinelHeight, []); + } +} diff --git a/test/DemaConsulting.Rendering.Skia.Tests/DemaConsulting.Rendering.Skia.Tests.csproj b/test/DemaConsulting.Rendering.Skia.Tests/DemaConsulting.Rendering.Skia.Tests.csproj new file mode 100644 index 0000000..63979d0 --- /dev/null +++ b/test/DemaConsulting.Rendering.Skia.Tests/DemaConsulting.Rendering.Skia.Tests.csproj @@ -0,0 +1,67 @@ + + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + Exe + false + true + true + + + true + true + true + latest + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/test/DemaConsulting.Rendering.Skia.Tests/PngEndMarkerTests.cs b/test/DemaConsulting.Rendering.Skia.Tests/PngEndMarkerTests.cs new file mode 100644 index 0000000..7135f4a --- /dev/null +++ b/test/DemaConsulting.Rendering.Skia.Tests/PngEndMarkerTests.cs @@ -0,0 +1,165 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Skia; +using SkiaSharp; + +namespace DemaConsulting.Rendering.Skia.Tests; + +/// +/// Tests for PNG line-end (connector decoration) markers. These confirm that the open chevron +/// is drawn OPEN (two strokes, no closing base edge), and that the PNG marker geometry matches +/// the shared source used by the SVG renderer (along-line overshoot +/// and across-line width), so SVG and PNG produce identical end-marker geometry. +/// +public sealed class PngEndMarkerTests +{ + private static SKBitmap RenderToBitmap(LayoutTree layout, RenderOptions options) + { + var renderer = new PngRenderer(); + using var ms = new MemoryStream(); + renderer.Render(layout, options, ms); + ms.Position = 0; + using var data = SKData.Create(ms); + return SKBitmap.Decode(data); + } + + /// A pixel is "ink" when it is noticeably darker than the white background. + private static bool IsInk(SKColor c) => c.Alpha > 32 && (c.Red + c.Green + c.Blue) < 600; + + /// Counts ink pixels within the given inclusive pixel rectangle. + private static int CountInk(SKBitmap bmp, int x0, int y0, int x1, int y1) + { + var count = 0; + for (var y = y0; y <= y1; y++) + { + for (var x = x0; x <= x1; x++) + { + if (IsInk(bmp.GetPixel(x, y))) + { + count++; + } + } + } + + return count; + } + + private static LayoutTree HorizontalLineTo(EndMarkerStyle target) + { + // Horizontal line from (10,50) to (150,50); marker is drawn at the (150,50) endpoint. + var line = new LayoutLine( + [new Point2D(10, 50), new Point2D(150, 50)], + EndMarkerStyle.None, + target, + LineStyle.Solid, + null); + return new LayoutTree(200, 100, [line]); + } + + /// + /// The open chevron is drawn OPEN: it has strictly fewer ink pixels in the marker zone than + /// the closed hollow triangle (which adds the closing base edge). + /// + [Fact] + public void OpenChevron_HasFewerInkPixelsThanClosedTriangle() + { + var options = new RenderOptions(Themes.Light); + using var chevron = RenderToBitmap(HorizontalLineTo(EndMarkerStyle.OpenChevron), options); + using var triangle = RenderToBitmap(HorizontalLineTo(EndMarkerStyle.HollowTriangle), options); + + // Marker zone: the back edge of the triangle marker sits near x=141 (refX 9 back from the + // x=150 endpoint). Count ink strictly behind the shaft join so the base edge is isolated. + var chevronInk = CountInk(chevron, 138, 42, 144, 58); + var triangleInk = CountInk(triangle, 138, 42, 144, 58); + + Assert.True( + chevronInk < triangleInk, + $"Expected open chevron ({chevronInk}) to have fewer base-edge ink pixels than closed triangle ({triangleInk})."); + } + + /// + /// The PNG filled-arrow spans along the line — + /// from the base (at behind the endpoint) to the apex + /// (overshooting the endpoint) — matching the SVG marker box length. Measured from the widest + /// (base) column to the furthest ink column. + /// + [Fact] + public void FilledArrow_AlongLength_MatchesNotationMetrics() + { + var options = new RenderOptions(Themes.Light); + using var bmp = RenderToBitmap(HorizontalLineTo(EndMarkerStyle.FilledArrow), options); + + // For each column near the endpoint, measure the vertical ink extent. The base column has the + // greatest extent (full marker width); the tip is the furthest column carrying marker ink. + var baseX = -1; + var baseExtent = 0; + var tipX = -1; + for (var x = 135; x <= 155; x++) + { + var minY = int.MaxValue; + var maxY = int.MinValue; + for (var y = 43; y <= 57; y++) + { + if (IsInk(bmp.GetPixel(x, y))) + { + minY = Math.Min(minY, y); + maxY = Math.Max(maxY, y); + } + } + + if (maxY < minY) + { + continue; + } + + var extent = maxY - minY + 1; + if (extent > baseExtent) + { + baseExtent = extent; + baseX = x; + } + + tipX = x; // furthest column with any ink + } + + Assert.True(baseX >= 0, "Expected to locate the marker base column."); + var alongLength = tipX - baseX; + + // Allow +/-2 px for anti-aliasing around the canonical 10-unit along length. + Assert.InRange(alongLength, NotationMetrics.EndMarkerLength - 2, NotationMetrics.EndMarkerLength + 2); + } + + /// + /// The PNG arrow base spans the shared across the + /// line, matching the SVG marker height. Measured at the marker base column. + /// + [Fact] + public void FilledArrow_BaseWidth_MatchesNotationMetrics() + { + var options = new RenderOptions(Themes.Light); + using var bmp = RenderToBitmap(HorizontalLineTo(EndMarkerStyle.FilledArrow), options); + + // Base column: refX (9) back from the x=150 endpoint => x≈141. + var baseX = (int)Math.Round(150 - NotationMetrics.EndMarkerRefX); + var minY = int.MaxValue; + var maxY = int.MinValue; + for (var y = 40; y <= 60; y++) + { + if (IsInk(bmp.GetPixel(baseX, y))) + { + minY = Math.Min(minY, y); + maxY = Math.Max(maxY, y); + } + } + + Assert.True(maxY >= minY, "Expected ink at the marker base column."); + var measuredWidth = maxY - minY + 1; + + // Allow +/-2 px for anti-aliasing around the canonical 7-unit width. + Assert.InRange(measuredWidth, NotationMetrics.EndMarkerWidth - 2, NotationMetrics.EndMarkerWidth + 2); + } +} diff --git a/test/DemaConsulting.Rendering.Skia.Tests/PngRendererPortedTests.cs b/test/DemaConsulting.Rendering.Skia.Tests/PngRendererPortedTests.cs new file mode 100644 index 0000000..0d306a9 --- /dev/null +++ b/test/DemaConsulting.Rendering.Skia.Tests/PngRendererPortedTests.cs @@ -0,0 +1,452 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Skia; +using SkiaSharp; + +namespace DemaConsulting.Rendering.Skia.Tests; + +/// +/// Tests for the PNG renderer. +/// +public sealed class PngRendererPortedTests +{ + /// + /// Helper: renders a to a decoded so + /// pixel values can be inspected in tests. + /// + /// Layout tree to render. + /// Render options providing theme and scale. + /// Decoded bitmap. Caller is responsible for disposing. + private static SKBitmap RenderToBitmap(LayoutTree layout, RenderOptions options) + { + var renderer = new PngRenderer(); + using var ms = new MemoryStream(); + renderer.Render(layout, options, ms); + ms.Position = 0; + using var data = SKData.Create(ms); + return SKBitmap.Decode(data); + } + + /// + /// Helper: parses a CSS hex color string such as #RRGGBB or #RRGGBBAA + /// using SkiaSharp. + /// + /// Hex color string to parse. + /// Parsed . + private static SKColor ParseHex(string hex) => SKColor.Parse(hex); + + /// + /// Helper: returns true when each RGB channel of is within + /// of the corresponding channel of . + /// A tolerance of 2 is sufficient to absorb sub-pixel anti-aliasing at well-interior pixels. + /// + /// Expected color. + /// Actual sampled color. + /// Maximum allowed difference per channel (0–255). + /// when all channels are within tolerance. + private static bool ColorNear(SKColor expected, SKColor actual, int tolerance = 2) => + Math.Abs(expected.Red - actual.Red) <= tolerance && + Math.Abs(expected.Green - actual.Green) <= tolerance && + Math.Abs(expected.Blue - actual.Blue) <= tolerance; + + /// + /// Render with an empty LayoutTree produces a non-empty output stream whose first + /// four bytes are the PNG signature bytes, confirming that a valid PNG is produced + /// for a minimal empty layout. + /// + [Fact] + public void PngRenderer_Render_EmptyTree_WritesPngSignature() + { + // Arrange: a renderer with a zero-size LayoutTree and default options + var renderer = new PngRenderer(); + var layout = new LayoutTree(0, 0, []); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the empty tree + renderer.Render(layout, options, output); + + // Assert: PNG signature bytes 0x89 0x50 0x4E 0x47 are present at offset 0 + Assert.True(output.Length > 4); + output.Position = 0; + var header = new byte[4]; + _ = output.Read(header, 0, 4); + Assert.Equal(0x89, header[0]); + Assert.Equal(0x50, header[1]); + Assert.Equal(0x4E, header[2]); + Assert.Equal(0x47, header[3]); + } + + /// + /// Render with a LayoutTree containing one LayoutBox produces a non-empty output + /// stream, confirming that box rendering does not throw and produces valid PNG. + /// + [Fact] + public void PngRenderer_Render_SingleBox_ProducesNonEmptyOutput() + { + // Arrange: a renderer with a tree containing one LayoutBox + var renderer = new PngRenderer(); + var box = new LayoutBox(10, 10, 100, 50, "TestBox", 0, BoxShape.Rectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree with one box + renderer.Render(layout, options, output); + + // Assert: output is a non-empty PNG stream + Assert.True(output.Length > 4); + output.Position = 0; + var header = new byte[4]; + _ = output.Read(header, 0, 4); + Assert.Equal(0x89, header[0]); + Assert.Equal(0x50, header[1]); + } + + /// + /// Render a LayoutBox at depth 0 and sample a pixel at the box center. The pixel + /// color must match the depth-0 fill color from Themes.Light, confirming that + /// boxes are filled with the correct theme color. + /// + [Fact] + public void PngRenderer_Render_SingleBox_FillColorMatchesTheme() + { + // Arrange: box at (10,10) 100×60 depth 0; center pixel is at (60, 40) + var box = new LayoutBox(10, 10, 100, 60, null, 0, BoxShape.Rectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: interior pixel matches depth-0 fill + var expected = ParseHex(Themes.Light.DepthFillColors[0]); + var actual = bmp.GetPixel(60, 40); + Assert.True(ColorNear(expected, actual), $"Expected {expected} ≈ {actual}"); + } + + /// + /// Render a LayoutBox at depth 1 and sample a pixel at the box center. The pixel + /// color must match the depth-1 fill color from Themes.Light, confirming that + /// depth-based fill colors are applied correctly. + /// + [Fact] + public void PngRenderer_Render_SingleBox_DepthOneUsesSecondColor() + { + // Arrange: box at (10,10) 100×60 depth 1; center pixel is at (60, 40) + var box = new LayoutBox(10, 10, 100, 60, null, 1, BoxShape.Rectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: interior pixel matches depth-1 fill + var expected = ParseHex(Themes.Light.DepthFillColors[1]); + var actual = bmp.GetPixel(60, 40); + Assert.True(ColorNear(expected, actual), $"Expected {expected} ≈ {actual}"); + } + + /// + /// Render an empty LayoutTree with a given theme and sample the pixel at (0, 0). The background + /// fill must equal that theme's background color, confirming the canvas is initialized from + /// rather than a hardcoded white. The dark theme (whose + /// background is not white) proves the fill is genuinely theme-driven. + /// + [Theory] + [InlineData("Light")] + [InlineData("Dark")] + public void PngRenderer_Render_BackgroundIsThemeBackground(string themeName) + { + // Arrange: empty tree at 100×100 rendered with the named theme + var theme = themeName == "Dark" ? Themes.Dark : Themes.Light; + var layout = new LayoutTree(100, 100, []); + var options = new RenderOptions(theme); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: top-left pixel equals the theme's background color + var expected = ParseHex(theme.BackgroundColor); + var actual = bmp.GetPixel(0, 0); + Assert.True(ColorNear(expected, actual), $"Expected theme background {expected} ≈ {actual}"); + } + + /// + /// Render a horizontal LayoutLine and sample a pixel on the line. The sampled pixel + /// color must approximate the theme stroke color, confirming that lines are drawn + /// with the correct stroke color. + /// + [Fact] + public void PngRenderer_Render_SingleLine_PixelOnLineIsStrokeColor() + { + // Arrange: horizontal line from (10,50) to (190,50) + var line = new LayoutLine( + [new Point2D(10, 50), new Point2D(190, 50)], + EndMarkerStyle.None, + EndMarkerStyle.None, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: pixel at the line midpoint is close to the stroke color + var strokeColor = ParseHex(Themes.Light.StrokeColor); + var actual = bmp.GetPixel(100, 50); + // Use a wider tolerance (80) for anti-aliased line pixels against white background + Assert.True(ColorNear(strokeColor, actual, tolerance: 80), $"Expected stroke {strokeColor} ≈ {actual}"); + } + + /// + /// Render a LayoutPort and sample a pixel at the port center. The pixel must + /// approximate the theme stroke color, confirming ports are rendered as filled squares. + /// + [Fact] + public void PngRenderer_Render_SinglePort_CenterPixelIsStrokeColor() + { + // Arrange: port at (50,50) right side; sample at center (50,50) + var port = new LayoutPort(50, 50, PortSide.Right, null); + var layout = new LayoutTree(200, 100, [port]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: center pixel is close to the stroke color (port is a filled square) + var strokeColor = ParseHex(Themes.Light.StrokeColor); + var actual = bmp.GetPixel(50, 50); + Assert.True(ColorNear(strokeColor, actual, tolerance: 10), $"Expected stroke {strokeColor} ≈ {actual}"); + } + + /// + /// Render a LayoutActivation bar and sample a pixel in its interior. The pixel must + /// be white, confirming activation bars are filled with white (not the background color). + /// + [Fact] + public void PngRenderer_Render_SingleActivation_CenterPixelIsWhite() + { + // Arrange: activation at CentreX=100, TopY=20, BottomY=80; interior pixel at (100,50) + var activation = new LayoutActivation(100, 20, 80); + var layout = new LayoutTree(200, 100, [activation]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: interior pixel is white + var actual = bmp.GetPixel(100, 50); + Assert.True(ColorNear(SKColors.White, actual), $"Expected white ≈ {actual}"); + } + + /// + /// Render a LayoutBand and scan its top border row. At least one pixel along the band's top + /// edge must approximate the theme stroke color, confirming the swim-lane band render path + /// executes and draws its border. + /// + [Fact] + public void PngRenderer_Render_SingleBand_BorderIsStrokeColor() + { + // Arrange: a horizontal band spanning x in [20,180] with its top edge at y=20 + var band = new LayoutBand(20, 20, 160, 60, BandOrientation.Horizontal, null, []); + var layout = new LayoutTree(200, 100, [band]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: some pixel on the band's top border row is drawn in the stroke color + var strokeColor = ParseHex(Themes.Light.StrokeColor); + var found = false; + for (var x = 20; x <= 180 && !found; x++) + { + if (ColorNear(strokeColor, bmp.GetPixel(x, 20), tolerance: 80)) + { + found = true; + } + } + + Assert.True(found, "Expected the band's top border to be drawn in the stroke color"); + } + + /// + /// Render a LayoutLifeline and sample the stem pixel. The pixel at the CentreX + /// midway down the stem must approximate the stroke color, confirming the stem is drawn. + /// + [Fact] + public void PngRenderer_Render_SingleLifeline_StemPixelIsStrokeColor() + { + // Arrange: lifeline at CentreX=100, TopY=10, BottomY=200; stem starts at y=50; sample at (100,125) + var lifeline = new LayoutLifeline(100, 10, 200, ":Actor", 80, 40); + var layout = new LayoutTree(300, 300, [lifeline]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: a pixel on the dashed stem is close to the stroke color + // Note: dashed lines may have gaps, so use wider tolerance and try a few Y positions + var strokeColor = ParseHex(Themes.Light.StrokeColor); + var foundStroke = false; + for (var y = 55; y < 90; y += 3) + { + var actual = bmp.GetPixel(100, y); + if (ColorNear(strokeColor, actual, tolerance: 80)) + { + foundStroke = true; + break; + } + } + Assert.True(foundStroke, "Expected to find stroke-colored pixel on lifeline stem"); + } + + /// + /// Render a LayoutGrid with a header row and sample the header cell pixel. The pixel + /// must match the depth-1 fill color, confirming header rows use the secondary fill color. + /// + [Fact] + public void PngRenderer_Render_SingleGrid_HeaderFillMatchesTheme() + { + // Arrange: grid at (10,10) with a 100×30 header cell; center pixel at (60, 25) + var headerRow = new LayoutGridRow(true, [new LayoutGridCell(100, 30, "Name", TextAlign.Left, 1)]); + var grid = new LayoutGrid(10, 10, [headerRow]); + var layout = new LayoutTree(200, 100, [grid]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: header cell interior pixel matches depth-1 fill + var expected = ParseHex(Themes.Light.DepthFillColors[1]); + var actual = bmp.GetPixel(60, 25); + Assert.True(ColorNear(expected, actual), $"Expected {expected} ≈ {actual}"); + } + + /// + /// Render a LayoutBadge with FilledCircle shape and sample the badge center pixel. + /// The pixel must approximate the theme stroke color, confirming that the filled-circle + /// badge is drawn with the stroke color as fill. + /// + [Fact] + public void PngRenderer_Render_SingleBadge_FilledCircle_CenterPixelIsStrokeColor() + { + // Arrange: filled-circle badge at (50,50) with size 20; center pixel at (50,50) + var badge = new LayoutBadge(50, 50, 20, BadgeShape.FilledCircle, null); + var layout = new LayoutTree(200, 100, [badge]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: badge center is stroke-colored + var strokeColor = ParseHex(Themes.Light.StrokeColor); + var actual = bmp.GetPixel(50, 50); + Assert.True(ColorNear(strokeColor, actual, tolerance: 10), $"Expected stroke {strokeColor} ≈ {actual}"); + } + + /// + /// Render a LayoutLine with an OpenWithCrossbar target arrowhead produces a non-empty + /// output stream beginning with the PNG signature bytes, confirming that the + /// open-with-crossbar arrowhead style renders without error. + /// + [Fact] + public void PngRenderer_Render_DrawArrowhead_OpenWithCrossbar_ProducesNonEmptyOutput() + { + // Arrange: a line with OpenWithCrossbar arrowhead at the target + var renderer = new PngRenderer(); + var line = new LayoutLine( + [new Point2D(10, 50), new Point2D(190, 50)], + EndMarkerStyle.None, + EndMarkerStyle.HollowTriangleCrossbar, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: output is a non-empty PNG stream + Assert.True(output.Length > 4); + output.Position = 0; + var header = new byte[4]; + _ = output.Read(header, 0, 4); + Assert.Equal(0x89, header[0]); + Assert.Equal(0x50, header[1]); + Assert.Equal(0x4E, header[2]); + Assert.Equal(0x47, header[3]); + } + + /// + /// Render a tree whose labels contain XML-special characters (< > & " ') + /// completes without throwing and produces a valid PNG, confirming the raster path treats + /// label text as literal glyphs rather than markup. + /// + [Fact] + public void PngRenderer_Render_LabelWithXmlSpecialCharacters_ProducesValidPng() + { + // Arrange: a tree whose box label and standalone label both contain XML-special characters. + const string special = "A < B & C > D \" E ' F"; + var renderer = new PngRenderer(); + var box = new LayoutBox(10, 10, 200, 60, special, 0, BoxShape.Rectangle, [], []); + var label = new LayoutLabel(20, 40, 200, special, TextAlign.Left, FontWeight.Regular, FontStyle.Normal, 12.0); + var layout = new LayoutTree(300, 200, [box, label]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree — must not throw on the special characters. + renderer.Render(layout, options, output); + + // Assert: a valid, non-empty PNG was produced. + Assert.True(output.Length > 4); + output.Position = 0; + var header = new byte[4]; + _ = output.Read(header, 0, 4); + Assert.Equal(0x89, header[0]); + Assert.Equal(0x50, header[1]); + Assert.Equal(0x4E, header[2]); + Assert.Equal(0x47, header[3]); + } + + /// + /// Render a moderately deeply-nested box tree (50 nesting levels) completes without a + /// stack overflow and produces a valid PNG, confirming the recursive renderer is robust at + /// realistic nesting depths. + /// + [Fact] + public void PngRenderer_Render_DeeplyNestedBoxes_DoesNotStackOverflow() + { + // Arrange: a chain of 50 nested boxes built from the innermost leaf outward. + const int depth = 50; + var renderer = new PngRenderer(); + LayoutNode node = new LayoutBox(0, 0, 10, 10, "leaf", depth, BoxShape.Rectangle, [], []); + for (var d = depth - 1; d >= 0; d--) + { + node = new LayoutBox(0, 0, 10 + d, 10 + d, $"n{d}", d, BoxShape.Rectangle, [], [node]); + } + + var layout = new LayoutTree(200, 200, [node]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render — the recursive descent must complete without overflowing the stack. + renderer.Render(layout, options, output); + + // Assert: a valid, non-empty PNG was produced. + Assert.True(output.Length > 4); + output.Position = 0; + var header = new byte[4]; + _ = output.Read(header, 0, 4); + Assert.Equal(0x89, header[0]); + Assert.Equal(0x50, header[1]); + Assert.Equal(0x4E, header[2]); + Assert.Equal(0x47, header[3]); + } +} diff --git a/test/DemaConsulting.Rendering.Skia.Tests/PngRendererTests.cs b/test/DemaConsulting.Rendering.Skia.Tests/PngRendererTests.cs new file mode 100644 index 0000000..b848246 --- /dev/null +++ b/test/DemaConsulting.Rendering.Skia.Tests/PngRendererTests.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Skia.Tests; + +/// +/// Smoke tests for , proving a placed layout tree renders to a PNG image. +/// +public class PngRendererTests +{ + /// + /// Proves that rendering a simple box produces a byte stream with the PNG file signature. + /// + [Fact] + public void Render_SingleBox_ProducesPngSignature() + { + var tree = new LayoutTree(100, 60, new LayoutNode[] + { + new LayoutBox(10, 10, 80, 40, "Box", 0, BoxShape.Rectangle, [], []), + }); + var renderer = new PngRenderer(); + using var stream = new MemoryStream(); + + renderer.Render(tree, new RenderOptions(Themes.Light), stream); + + var bytes = stream.ToArray(); + Assert.True(bytes.Length > 8); + + // PNG files begin with the 8-byte signature 89 50 4E 47 0D 0A 1A 0A. + var signature = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + Assert.Equal(signature, bytes[..8]); + Assert.Equal("image/png", renderer.MediaType); + } +} diff --git a/test/DemaConsulting.Rendering.Skia.Tests/SkiaFormatRendererTests.cs b/test/DemaConsulting.Rendering.Skia.Tests/SkiaFormatRendererTests.cs new file mode 100644 index 0000000..78a86e7 --- /dev/null +++ b/test/DemaConsulting.Rendering.Skia.Tests/SkiaFormatRendererTests.cs @@ -0,0 +1,80 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Text; + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Skia.Tests; + +/// +/// Tests for the SkiaSharp raster renderers that share a rasterization core but emit different +/// image formats (, , ). +/// +public class SkiaFormatRendererTests +{ + private static LayoutTree SampleTree() => new(100, 60, new LayoutNode[] + { + new LayoutBox(10, 10, 80, 40, "Box", 0, BoxShape.Rectangle, [], []), + }); + + /// + /// Proves that the JPEG renderer produces a byte stream with the JPEG SOI signature and + /// advertises the JPEG media type and extensions. + /// + [Fact] + public void JpegRenderer_Render_ProducesJpegSignature() + { + var renderer = new JpegRenderer(); + using var stream = new MemoryStream(); + + renderer.Render(SampleTree(), new RenderOptions(Themes.Light), stream); + + var bytes = stream.ToArray(); + Assert.True(bytes.Length > 3); + + // JPEG streams begin with the Start-Of-Image marker FF D8 FF. + Assert.Equal(0xFF, bytes[0]); + Assert.Equal(0xD8, bytes[1]); + Assert.Equal(0xFF, bytes[2]); + Assert.Equal("image/jpeg", renderer.MediaType); + Assert.Equal(".jpg", renderer.DefaultExtension); + Assert.Contains(".jpeg", renderer.FileExtensions); + } + + /// + /// Proves that the WEBP renderer produces a byte stream with the RIFF/WEBP container header + /// and advertises the WEBP media type and extension. + /// + [Fact] + public void WebpRenderer_Render_ProducesWebpContainerHeader() + { + var renderer = new WebpRenderer(); + using var stream = new MemoryStream(); + + renderer.Render(SampleTree(), new RenderOptions(Themes.Light), stream); + + var bytes = stream.ToArray(); + Assert.True(bytes.Length > 12); + + // WEBP is a RIFF container: bytes 0-3 spell "RIFF" and bytes 8-11 spell "WEBP". + Assert.Equal("RIFF", Encoding.ASCII.GetString(bytes, 0, 4)); + Assert.Equal("WEBP", Encoding.ASCII.GetString(bytes, 8, 4)); + Assert.Equal("image/webp", renderer.MediaType); + Assert.Equal(".webp", renderer.DefaultExtension); + } + + /// + /// Proves that the PNG renderer advertises a single .png extension that includes its default. + /// + [Fact] + public void PngRenderer_FileExtensions_ContainsDefault() + { + var renderer = new PngRenderer(); + + Assert.Contains(renderer.DefaultExtension, renderer.FileExtensions); + Assert.Equal(".png", renderer.DefaultExtension); + } +} diff --git a/test/DemaConsulting.Rendering.Svg.Tests/DemaConsulting.Rendering.Svg.Tests.csproj b/test/DemaConsulting.Rendering.Svg.Tests/DemaConsulting.Rendering.Svg.Tests.csproj new file mode 100644 index 0000000..2dca4af --- /dev/null +++ b/test/DemaConsulting.Rendering.Svg.Tests/DemaConsulting.Rendering.Svg.Tests.csproj @@ -0,0 +1,67 @@ + + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + Exe + false + true + true + + + true + true + true + latest + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/test/DemaConsulting.Rendering.Svg.Tests/SvgEndMarkerTests.cs b/test/DemaConsulting.Rendering.Svg.Tests/SvgEndMarkerTests.cs new file mode 100644 index 0000000..b23049a --- /dev/null +++ b/test/DemaConsulting.Rendering.Svg.Tests/SvgEndMarkerTests.cs @@ -0,0 +1,115 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Svg; + +namespace DemaConsulting.Rendering.Svg.Tests; + +/// +/// Tests for the SVG line-end (connector decoration) markers: open chevron is drawn OPEN, the +/// closed shapes stay closed, and every marker dimension is derived from +/// (so SVG and PNG share one size source). +/// +public sealed class SvgEndMarkerTests +{ + private static string RenderLine(EndMarkerStyle source, EndMarkerStyle target) + { + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 10)], + source, + target, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + renderer.Render(layout, options, output); + output.Position = 0; + return new StreamReader(output).ReadToEnd(); + } + + /// The open-chevron marker is defined as an OPEN <polyline>, not a closed polygon. + [Fact] + public void OpenChevron_IsDefinedAsPolyline() + { + var svg = RenderLine(EndMarkerStyle.None, EndMarkerStyle.OpenChevron); + + // The marker definition immediately following the open-chevron id must be a polyline (open), + // never a polygon (closed). + var markerIndex = svg.IndexOf("id=\"line-end-open-chevron\"", StringComparison.Ordinal); + Assert.True(markerIndex >= 0, "Expected the open-chevron marker definition to be present."); + + var markerEnd = svg.IndexOf("", markerIndex, StringComparison.Ordinal); + var markerBody = svg[markerIndex..markerEnd]; + Assert.Contains("The hollow-triangle marker stays a CLOSED <polygon>. + [Fact] + public void HollowTriangle_IsDefinedAsClosedPolygon() + { + var svg = RenderLine(EndMarkerStyle.None, EndMarkerStyle.HollowTriangle); + + var markerIndex = svg.IndexOf("id=\"line-end-hollow-triangle\"", StringComparison.Ordinal); + Assert.True(markerIndex >= 0, "Expected the hollow-triangle marker definition to be present."); + + var markerEnd = svg.IndexOf("", markerIndex, StringComparison.Ordinal); + var markerBody = svg[markerIndex..markerEnd]; + Assert.Contains(" + /// The triangle marker box dimensions are exactly the canonical + /// values (10 x 7, refX 9), proving the SVG markers derive from the single source. + /// + [Fact] + public void TriangleMarker_DimensionsDeriveFromNotationMetrics() + { + var svg = RenderLine(EndMarkerStyle.None, EndMarkerStyle.HollowTriangle); + + var expected = + $"markerWidth=\"{Num(NotationMetrics.EndMarkerLength)}\" " + + $"markerHeight=\"{Num(NotationMetrics.EndMarkerWidth)}\" " + + $"refX=\"{Num(NotationMetrics.EndMarkerRefX)}\" " + + $"refY=\"{Num(NotationMetrics.EndMarkerHalfWidth)}\""; + Assert.Contains(expected, svg, StringComparison.Ordinal); + + // The polygon points are the box mapping of the shared triangle vertices. + Assert.Contains("points=\"0 0, 10 3.5, 0 7\"", svg, StringComparison.Ordinal); + } + + /// + /// The diamond marker box dimensions are exactly the canonical + /// values (14 x 8, refX 13), and its points are the shared diamond vertices. + /// + [Fact] + public void DiamondMarker_DimensionsDeriveFromNotationMetrics() + { + var svg = RenderLine(EndMarkerStyle.None, EndMarkerStyle.HollowDiamond); + + var expected = + $"markerWidth=\"{Num(NotationMetrics.DiamondLength)}\" " + + $"markerHeight=\"{Num(NotationMetrics.DiamondWidth)}\" " + + $"refX=\"{Num(NotationMetrics.DiamondRefX)}\" " + + $"refY=\"{Num(NotationMetrics.DiamondHalfWidth)}\""; + Assert.Contains(expected, svg, StringComparison.Ordinal); + Assert.Contains("points=\"1 4, 7 0, 13 4, 7 8\"", svg, StringComparison.Ordinal); + } + + /// An open-chevron line references the open-chevron marker via marker-end. + [Fact] + public void OpenChevronLine_ReferencesOpenChevronMarker() + { + var svg = RenderLine(EndMarkerStyle.None, EndMarkerStyle.OpenChevron); + Assert.Contains("marker-end=\"url(#line-end-open-chevron)\"", svg, StringComparison.Ordinal); + } + + private static string Num(double value) => + Math.Round(value, 6).ToString(System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs new file mode 100644 index 0000000..b261a89 --- /dev/null +++ b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs @@ -0,0 +1,561 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Svg; + +namespace DemaConsulting.Rendering.Svg.Tests; + +/// +/// Tests for the SVG renderer. +/// +public sealed class SvgRendererPortedTests +{ + /// + /// Render with an empty LayoutTree produces a non-empty output stream whose content + /// contains the SVG root element, confirming basic SVG document generation. + /// + [Fact] + public void SvgRenderer_Render_EmptyTree_ProducesSvgDocument() + { + // Arrange: a renderer with an empty LayoutTree and default options + var renderer = new SvgRenderer(); + var layout = new LayoutTree(400, 300, []); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the empty tree + renderer.Render(layout, options, output); + + // Assert: output is non-empty and contains the SVG root element + Assert.True(output.Length > 0); + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("", svgText, StringComparison.Ordinal); + } + + /// + /// Render with a LayoutTree containing one LayoutBox produces SVG output that + /// contains a rect element, confirming that boxes are translated to SVG rectangles. + /// + [Fact] + public void SvgRenderer_Render_SingleBox_ProducesRectElement() + { + // Arrange: a renderer with a tree containing one LayoutBox + var renderer = new SvgRenderer(); + var box = new LayoutBox(10, 10, 100, 50, "MyBox", 0, BoxShape.Rectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree with one box + renderer.Render(layout, options, output); + + // Assert: output contains a rect element + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render with a LayoutTree containing one LayoutLabel produces SVG output that + /// contains a text element, confirming that labels are translated to SVG text nodes. + /// + [Fact] + public void SvgRenderer_Render_SingleLabel_ProducesTextElement() + { + // Arrange: a renderer with a tree containing one LayoutLabel + var renderer = new SvgRenderer(); + var label = new LayoutLabel(50, 75, 200, "Hello World", TextAlign.Center, FontWeight.Regular, FontStyle.Normal, 12.0); + var layout = new LayoutTree(200, 100, [label]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree with one label + renderer.Render(layout, options, output); + + // Assert: output contains a text element + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render with a LayoutTree containing one LayoutLine produces SVG output that + /// contains a path element, confirming that lines are translated to SVG paths. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_ProducesPathElement() + { + // Arrange: a renderer with a tree containing one LayoutLine + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 90)], + EndMarkerStyle.None, + EndMarkerStyle.HollowTriangle, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree with one line + renderer.Render(layout, options, output); + + // Assert: output contains a path element + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutLine with 3 waypoints and a positive LineCornerRadius theme + /// produces SVG output containing an arc command (" A ") in the path data, + /// confirming that corner rounding generates arc segments. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath() + { + // Arrange: a line with an interior bend that triggers arc generation + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(10, 50), new Point2D(90, 50)], + EndMarkerStyle.None, + EndMarkerStyle.None, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); // LineCornerRadius = 4.0 + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: arc command is present in path data + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" A ", svgText, StringComparison.Ordinal); + } + + /// + /// Render a dashed LayoutLine produces SVG output containing the stroke-dasharray + /// attribute, confirming that dashed line style is mapped to SVG dash patterns. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_Dashed_ProducesDashArray() + { + // Arrange: a dashed line + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 10)], + EndMarkerStyle.None, + EndMarkerStyle.None, + LineStyle.Dashed, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("stroke-dasharray", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutLine with a HollowTriangle target arrowhead produces SVG output containing + /// a marker-end attribute, confirming arrowhead markers are referenced correctly. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_WithHollowTriangleArrowhead_ProducesMarkerEnd() + { + // Arrange: a line with a hollow-triangle arrowhead at the target + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 10)], + EndMarkerStyle.None, + EndMarkerStyle.HollowTriangle, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("marker-end", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutLine with a Diamond source arrowhead produces SVG output containing + /// the line-end-hollow-diamond marker id, confirming diamond markers are defined and referenced. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_WithDiamondArrowhead_ProducesDiamondMarker() + { + // Arrange: a line with Diamond arrowhead at the source + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 10)], + EndMarkerStyle.HollowDiamond, + EndMarkerStyle.None, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: the connector path references the hollow-diamond marker on its source end + // (not merely that the marker id appears in ). + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("marker-start=\"url(#line-end-hollow-diamond)\"", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutBox with a LayoutCompartment produces SVG output containing a + /// line element (compartment divider) and compartment row text, confirming that + /// compartment rendering is complete. + /// + [Fact] + public void SvgRenderer_Render_BoxWithCompartment_ProducesLineAndText() + { + // Arrange: a box with one compartment that has a body row + var renderer = new SvgRenderer(); + var compartment = new LayoutCompartment(null, ["+ radius : Real"]); + var box = new LayoutBox(10, 10, 150, 80, "MyBlock", 0, BoxShape.Rectangle, [compartment], []); + var layout = new LayoutTree(200, 120, [box]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: divider line and compartment row text are both present + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutBox with RoundedRectangle shape produces SVG output containing an + /// rx attribute, confirming that rounded corners are applied via the rx/ry attributes. + /// + [Fact] + public void SvgRenderer_Render_BoxRoundedRectangle_ProducesRxAttribute() + { + // Arrange: a rounded-rectangle box + var renderer = new SvgRenderer(); + var box = new LayoutBox(10, 10, 100, 50, "Rounded", 0, BoxShape.RoundedRectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); // LineCornerRadius = 4.0 + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("rx=\"", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutPort produces SVG output containing a rect element, + /// confirming that ports are rendered as filled squares. + /// + [Fact] + public void SvgRenderer_Render_SinglePort_ProducesRect() + { + // Arrange: a port on the right side + var renderer = new SvgRenderer(); + var port = new LayoutPort(100, 50, PortSide.Right, "p1"); + var layout = new LayoutTree(200, 100, [port]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutBadge with FilledCircle shape produces SVG output containing a + /// circle element, confirming that filled-circle badges are rendered as SVG circles. + /// + [Fact] + public void SvgRenderer_Render_SingleBadge_FilledCircle_ProducesCircle() + { + // Arrange: a filled-circle badge + var renderer = new SvgRenderer(); + var badge = new LayoutBadge(50, 50, 12, BadgeShape.FilledCircle, "I"); + var layout = new LayoutTree(200, 100, [badge]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutBand produces SVG output containing a rect element, + /// confirming that swim-lane bands are rendered as rectangles. + /// + [Fact] + public void SvgRenderer_Render_SingleBand_ProducesRect() + { + // Arrange: a horizontal swim-lane band + var renderer = new SvgRenderer(); + var band = new LayoutBand(10, 10, 300, 100, BandOrientation.Horizontal, "Lane A", []); + var layout = new LayoutTree(400, 200, [band]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutLifeline produces SVG output containing both a rect element + /// (the header box) and a line element (the dashed stem), confirming that both + /// components of a lifeline are rendered. + /// + [Fact] + public void SvgRenderer_Render_SingleLifeline_ProducesRectAndLine() + { + // Arrange: a lifeline with a header box and a stem + var renderer = new SvgRenderer(); + var lifeline = new LayoutLifeline(100, 10, 300, ":Actor", 80, 30); + var layout = new LayoutTree(300, 400, [lifeline]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutActivation produces SVG output containing a rect element, + /// confirming that activation bars are rendered as narrow rectangles. + /// + [Fact] + public void SvgRenderer_Render_SingleActivation_ProducesRect() + { + // Arrange: a narrow activation bar + var renderer = new SvgRenderer(); + var activation = new LayoutActivation(100, 50, 200); + var layout = new LayoutTree(300, 400, [activation]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutGrid produces SVG output containing at least one rect element, + /// confirming that grid cells are rendered as bordered rectangles. + /// + [Fact] + public void SvgRenderer_Render_SingleGrid_ProducesRects() + { + // Arrange: a 1x2 grid with one header and one body row + var renderer = new SvgRenderer(); + var headerRow = new LayoutGridRow(true, [new LayoutGridCell(100, 24, "Name", TextAlign.Left, 1)]); + var bodyRow = new LayoutGridRow(false, [new LayoutGridCell(100, 24, "Alice", TextAlign.Left, 1)]); + var grid = new LayoutGrid(10, 10, [headerRow, bodyRow]); + var layout = new LayoutTree(200, 100, [grid]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutLabel with FontWeight.Bold produces SVG output containing + /// font-weight="bold", confirming that bold labels apply the bold font weight attribute. + /// + [Fact] + public void SvgRenderer_Render_LabelWithBold_ProducesBoldAttribute() + { + // Arrange: a label with bold weight + var renderer = new SvgRenderer(); + var label = new LayoutLabel(50, 50, 200, "Bold Text", TextAlign.Left, FontWeight.Bold, FontStyle.Normal, 14.0); + var layout = new LayoutTree(300, 100, [label]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("font-weight=\"bold\"", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutLabel with FontStyle.Italic produces SVG output containing + /// font-style="italic", confirming that italic labels apply the italic font style attribute. + /// + [Fact] + public void SvgRenderer_Render_LabelWithItalic_ProducesItalicAttribute() + { + // Arrange: a label with italic style + var renderer = new SvgRenderer(); + var label = new LayoutLabel(50, 50, 200, "Italic Text", TextAlign.Left, FontWeight.Regular, FontStyle.Italic, 14.0); + var layout = new LayoutTree(300, 100, [label]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("font-style=\"italic\"", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutLine with a non-null MidpointLabel produces SVG output containing + /// a text element, confirming that midpoint labels are rendered over the line. + /// + [Fact] + public void SvgRenderer_Render_LineWithMidpointLabel_ProducesTextElement() + { + // Arrange: a line with a midpoint label + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 50), new Point2D(190, 50)], + EndMarkerStyle.None, + EndMarkerStyle.None, + LineStyle.Solid, + "uses"); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutLine with an OpenWithCrossbar target arrowhead produces SVG output + /// containing the line-end-hollow-triangle-crossbar marker id, confirming the open-crossbar marker + /// is defined in the defs block and referenced by the path element. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_WithOpenCrossbarArrowhead_ProducesOpenCrossbarMarker() + { + // Arrange: a line with OpenWithCrossbar arrowhead at the target + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 10)], + EndMarkerStyle.None, + EndMarkerStyle.HollowTriangleCrossbar, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: the connector path references the hollow-triangle-crossbar marker on its target end + // (not merely that the marker id appears in ). + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("marker-end=\"url(#line-end-hollow-triangle-crossbar)\"", svgText, StringComparison.Ordinal); + } + + /// + /// Render a tree whose box label and standalone label both contain XML-special characters + /// (< > & " ') produces well-formed SVG in which the reserved markup + /// characters are escaped, confirming that label text can never break the SVG document. + /// + [Fact] + public void SvgRenderer_Render_LabelWithXmlSpecialCharacters_ProducesWellFormedEscapedSvg() + { + // Arrange: a tree whose box label and standalone label both contain XML-special characters. + const string special = "A < B & C > D \" E ' F"; + var renderer = new SvgRenderer(); + var box = new LayoutBox(10, 10, 200, 60, special, 0, BoxShape.Rectangle, [], []); + var label = new LayoutLabel(20, 40, 200, special, TextAlign.Left, FontWeight.Regular, FontStyle.Normal, 12.0); + var layout = new LayoutTree(300, 200, [box, label]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree. + renderer.Render(layout, options, output); + + // Assert: the output parses as well-formed XML and the reserved characters are escaped. + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + var document = System.Xml.Linq.XDocument.Parse(svgText); // throws if the SVG is not well-formed + Assert.Contains("<", svgText, StringComparison.Ordinal); + Assert.Contains(">", svgText, StringComparison.Ordinal); + Assert.Contains("&", svgText, StringComparison.Ordinal); + + // Assert: after parsing, a text element's content round-trips back to the original label, + // proving the special characters were escaped rather than emitted raw. + var textValues = document.Descendants() + .Where(e => e.Name.LocalName == "text") + .Select(e => e.Value) + .ToList(); + Assert.Contains(textValues, v => v.Contains(special, StringComparison.Ordinal)); + } +} diff --git a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererTests.cs b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererTests.cs new file mode 100644 index 0000000..96b574b --- /dev/null +++ b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererTests.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Text; + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Abstractions; + +namespace DemaConsulting.Rendering.Svg.Tests; + +/// +/// Smoke tests for , proving a placed layout tree renders to SVG. +/// +public class SvgRendererTests +{ + /// + /// Proves that rendering a simple box produces a well-formed SVG document. + /// + [Fact] + public void SvgRenderer_Render_SingleBox_ProducesSvgDocument() + { + // Arrange: a placed layout tree containing a single box + var tree = new LayoutTree(100, 60, new LayoutNode[] + { + new LayoutBox(10, 10, 80, 40, "Box", 0, BoxShape.Rectangle, [], []), + }); + var renderer = new SvgRenderer(); + using var stream = new MemoryStream(); + + // Act: render the tree to an SVG stream + renderer.Render(tree, new RenderOptions(Themes.Light), stream); + + // Assert: a well-formed SVG document is produced and the renderer advertises SVG metadata + var svg = Encoding.UTF8.GetString(stream.ToArray()); + Assert.Contains(" - net481;net8.0;net9.0;net10.0 - net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 latest enable enable @@ -64,3 +63,4 @@ + diff --git a/test/DemaConsulting.Rendering.Tests/DemoTests.cs b/test/DemaConsulting.Rendering.Tests/DemoTests.cs deleted file mode 100644 index afd2247..0000000 --- a/test/DemaConsulting.Rendering.Tests/DemoTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using DemaConsulting.Rendering; - -namespace DemaConsulting.Rendering.Tests; - -/// -/// Unit tests for the Demo class. -/// -public class DemoTests -{ - /// - /// Proves that DemoMethod produces the expected "{prefix}, {name}!" format - /// when the default constructor is used. - /// - [Fact] - public void Demo_DemoMethod_DefaultPrefix_ReturnsGreeting() - { - // Arrange: set up Demo with default constructor and test name - var demo = new Demo(); - const string name = "World"; - - // Act: call DemoMethod with test name - var result = demo.DemoMethod(name); - - // Assert: greeting must be exactly "Hello, World!" - Assert.Equal("Hello, World!", result); - } - - /// - /// Proves that DemoMethod uses the custom prefix supplied at construction - /// time instead of the default. - /// - [Fact] - public void Demo_DemoMethod_CustomPrefix_ReturnsGreeting() - { - // Arrange: set up Demo with custom prefix and test name - var demo = new Demo("Hi"); - const string name = "Alice"; - - // Act: call DemoMethod with test name - var result = demo.DemoMethod(name); - - // Assert: greeting must use the custom prefix "Hi" - Assert.Equal("Hi, Alice!", result); - } - - /// - /// Proves that DemoMethod throws ArgumentNullException (not a base - /// ArgumentException) when a null name is supplied. - /// - [Fact] - public void Demo_DemoMethod_NullInput_ThrowsArgumentNullException() - { - // Arrange: set up Demo with default constructor - var demo = new Demo(); - - // Act & Assert: exact exception type proves null is explicitly rejected - Assert.Throws(() => demo.DemoMethod(null!)); - } - - /// - /// Proves that DemoMethod throws ArgumentException (not ArgumentNullException) - /// when an empty string name is supplied. - /// - [Fact] - public void Demo_DemoMethod_EmptyInput_ThrowsArgumentException() - { - // Arrange: set up Demo with default constructor - var demo = new Demo(); - - // Act & Assert: ArgumentException (not the null sub-type) must be thrown - Assert.Throws(() => demo.DemoMethod(string.Empty)); - } - - /// - /// Proves that the custom-prefix constructor throws ArgumentNullException - /// (not a base ArgumentException) when a null prefix is supplied. - /// - [Fact] - public void Demo_Constructor_NullPrefix_ThrowsArgumentNullException() - { - // Act & Assert: exact exception type proves null is explicitly rejected - Assert.Throws(() => new Demo(null!)); - } - - /// - /// Proves that the custom-prefix constructor throws ArgumentException - /// (not ArgumentNullException) when an empty string prefix is supplied. - /// - [Fact] - public void Demo_Constructor_EmptyPrefix_ThrowsArgumentException() - { - // Act & Assert: ArgumentException (not the null sub-type) must be thrown - Assert.Throws(() => new Demo(string.Empty)); - } - - /// - /// Proves that the DefaultPrefix constant exposes the value "Hello", - /// which is the expected default greeting prefix. - /// - [Fact] - public void Demo_DefaultPrefix_Read_IsHello() - { - // Arrange: set expected value - const string expected = "Hello"; - - // Act: read the public constant directly - var actual = Demo.DefaultPrefix; - - // Assert: constant value must not silently change - Assert.Equal(expected, actual); - } - - /// - /// Proves that the Prefix property returns the custom value provided to - /// the constructor, confirming the property reflects what was stored. - /// - [Fact] - public void Demo_Prefix_WithCustomConstruction_ReturnsCustomPrefix() - { - // Arrange: set up Demo with custom prefix - const string customPrefix = "Greetings"; - var demo = new Demo(customPrefix); - - // Act: read the Prefix property - var actual = demo.Prefix; - - // Assert: Prefix must exactly match the value passed at construction - Assert.Equal(customPrefix, actual); - } - - /// - /// Proves that the default constructor stores DefaultPrefix in the Prefix - /// property, tying the constant and the property together explicitly. - /// - [Fact] - public void Demo_DefaultConstructor_WithNoArgs_SetsDefaultPrefix() - { - // Arrange: set up Demo with default constructor - var demo = new Demo(); - - // Act: read the Prefix property - var actual = demo.Prefix; - - // Assert: default constructor must yield exactly Demo.DefaultPrefix - Assert.Equal(Demo.DefaultPrefix, actual); - } -} diff --git a/test/DemaConsulting.Rendering.Tests/LayoutGraphTests.cs b/test/DemaConsulting.Rendering.Tests/LayoutGraphTests.cs new file mode 100644 index 0000000..edb1e74 --- /dev/null +++ b/test/DemaConsulting.Rendering.Tests/LayoutGraphTests.cs @@ -0,0 +1,309 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Tests; + +/// +/// Tests for the input model and its element factory methods. +/// +public class LayoutGraphTests +{ + /// + /// Proves that AddNode appends a node and returns it with the requested identity and size. + /// + [Fact] + public void AddNode_AppendsNodeAndReturnsIt() + { + // Arrange: create an empty graph + var graph = new LayoutGraph(); + + // Act: add a sized node + var node = graph.AddNode("a", 80, 40); + + // Assert: the node is appended and carries the requested identity and size + Assert.Single(graph.Nodes); + Assert.Equal("a", node.Id); + Assert.Equal(80, node.Width); + Assert.Equal(40, node.Height); + } + + /// + /// Proves that AddEdge appends a directed edge referencing the given endpoints. + /// + [Fact] + public void AddEdge_AppendsEdgeWithEndpoints() + { + // Arrange: create a graph with two nodes + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 1, 1); + var b = graph.AddNode("b", 1, 1); + + // Act: connect the two nodes with an edge + var edge = graph.AddEdge("e", a, b); + + // Assert: the edge is appended and references both endpoints + Assert.Single(graph.Edges); + Assert.Same(a, edge.Source); + Assert.Same(b, edge.Target); + } + + /// + /// Proves that a per-element property override is stored on the node itself. + /// + [Fact] + public void Node_CarriesPerElementProperties() + { + // Arrange: create a graph with a single node + var graph = new LayoutGraph(); + var node = graph.AddNode("a", 1, 1); + + // Act: attach a per-node property override + node.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + + // Assert: the override is stored on the node + Assert.Equal(LayoutFlowDirection.Down, node.Get(CoreOptions.Direction)); + } + + /// + /// Proves that AddNode rejects a null identifier. + /// + [Fact] + public void AddNode_NullId_ThrowsArgumentNullException() + { + // Arrange: create an empty graph + var graph = new LayoutGraph(); + + // Act / Assert: a null id is rejected + Assert.Throws(() => graph.AddNode(null!, 1, 1)); + } + + /// + /// Proves that AddNode rejects an empty identifier. + /// + [Fact] + public void AddNode_EmptyId_ThrowsArgumentException() + { + // Arrange: create an empty graph + var graph = new LayoutGraph(); + + // Act / Assert: an empty id is rejected + Assert.Throws(() => graph.AddNode(string.Empty, 1, 1)); + } + + /// + /// Proves that AddNode enforces node-identifier uniqueness within the graph. + /// + [Fact] + public void AddNode_DuplicateId_ThrowsArgumentException() + { + // Arrange: create a graph that already contains a node "a" + var graph = new LayoutGraph(); + graph.AddNode("a", 1, 1); + + // Act / Assert: a second node with the same id is rejected and not appended + Assert.Throws(() => graph.AddNode("a", 2, 2)); + Assert.Single(graph.Nodes); + } + + /// + /// Proves that AddEdge rejects a null identifier. + /// + [Fact] + public void AddEdge_NullId_ThrowsArgumentNullException() + { + // Arrange: create a graph with two nodes + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 1, 1); + var b = graph.AddNode("b", 1, 1); + + // Act / Assert: a null id is rejected + Assert.Throws(() => graph.AddEdge(null!, a, b)); + } + + /// + /// Proves that AddEdge rejects a null endpoint. + /// + [Fact] + public void AddEdge_NullSource_ThrowsArgumentNullException() + { + // Arrange: create a graph with a single target node + var graph = new LayoutGraph(); + var b = graph.AddNode("b", 1, 1); + + // Act / Assert: a null source endpoint is rejected + Assert.Throws(() => graph.AddEdge("e", null!, b)); + } + + /// + /// Proves that AddEdge enforces edge-identifier uniqueness within the graph. + /// + [Fact] + public void AddEdge_DuplicateId_ThrowsArgumentException() + { + // Arrange: create a graph with two nodes and an edge "e" + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 1, 1); + var b = graph.AddNode("b", 1, 1); + graph.AddEdge("e", a, b); + + // Act / Assert: a second edge with the same id is rejected and not appended + Assert.Throws(() => graph.AddEdge("e", b, a)); + Assert.Single(graph.Edges); + } + + /// + /// Proves that a leaf node reports it is not a container and materializes no child subgraph. + /// + [Fact] + public void LayoutGraphNode_HasChildren_LeafNode_ReturnsFalse() + { + // Arrange: create a graph with a single, unnested node + var graph = new LayoutGraph(); + var leaf = graph.AddNode("leaf", 80, 40); + + // Act: query the container flag without ever accessing Children + var isContainer = leaf.HasChildren; + + // Assert: the leaf is not a container (and no child subgraph was allocated to answer) + Assert.False(isContainer); + } + + /// + /// Proves that a container node holds its nested child nodes and contained edges through its + /// own child subgraph and reports itself as a container. + /// + [Fact] + public void LayoutGraphNode_Children_ContainerNode_HoldsChildNodesAndEdges() + { + // Arrange: create a container node at the root scope + var graph = new LayoutGraph(); + var group = graph.AddNode("group", 200, 120); + + // Act: populate the container's child subgraph with two nodes and an intra-container edge + var inner1 = group.Children.AddNode("child1", 80, 40); + var inner2 = group.Children.AddNode("child2", 80, 40); + var innerEdge = group.Children.AddEdge("inner-edge", inner1, inner2); + + // Assert: the node is now a container whose children and contained edge are retrievable + Assert.True(group.HasChildren); + Assert.Equal(2, group.Children.Nodes.Count); + Assert.Single(group.Children.Edges); + Assert.Same(inner1, innerEdge.Source); + Assert.Same(inner2, innerEdge.Target); + } + + /// + /// Proves that identifiers are scoped per container: the same identifier may be reused in a + /// different scope without conflict. + /// + [Fact] + public void LayoutGraph_AddNode_ChildScope_AllowsIdReuseAcrossScopes() + { + // Arrange: create two container nodes that each open their own identifier scope + var graph = new LayoutGraph(); + var left = graph.AddNode("left", 100, 100); + var right = graph.AddNode("right", 100, 100); + + // Act: add a node named "x" inside each container, reusing the same identifier per scope + var leftX = left.Children.AddNode("x", 40, 40); + var rightX = right.Children.AddNode("x", 40, 40); + + // Assert: both scopes accept the shared identifier and hold distinct node instances + Assert.Equal("x", leftX.Id); + Assert.Equal("x", rightX.Id); + Assert.NotSame(leftX, rightX); + } + + /// + /// Proves that identifier uniqueness is enforced within a single container scope. + /// + [Fact] + public void LayoutGraph_AddNode_ChildScope_DuplicateId_ThrowsArgumentException() + { + // Arrange: create a container that already holds a child node "dup" + var graph = new LayoutGraph(); + var container = graph.AddNode("container", 100, 100); + container.Children.AddNode("dup", 40, 40); + + // Act / Assert: a second child with the same id in the same scope is rejected + Assert.Throws(() => container.Children.AddNode("dup", 50, 50)); + Assert.Single(container.Children.Nodes); + } + + /// + /// Proves that a cross-container edge referencing a descendant node is constructible at an + /// ancestor (here the root) scope. + /// + [Fact] + public void LayoutGraphEdge_CrossContainer_ReferencingDescendant_ConstructibleAtRoot() + { + // Arrange: a root graph with a leaf node and a container holding a descendant node + var graph = new LayoutGraph(); + var outside = graph.AddNode("outside", 80, 40); + var group = graph.AddNode("group", 200, 120); + var inner = group.Children.AddNode("inner", 80, 40); + + // Act: add an edge at the root (the lowest common ancestor) that spans into the container + var cross = graph.AddEdge("cross-edge", outside, inner); + + // Assert: the edge lives in the ancestor container yet references the descendant endpoints + Assert.Single(graph.Edges); + Assert.Same(outside, cross.Source); + Assert.Same(inner, cross.Target); + Assert.DoesNotContain(inner, graph.Nodes); + } + + /// + /// Proves that an edge identifier used in one container scope may be reused in a different + /// scope, confirming edge identifiers are scoped per container just like node identifiers. + /// + [Fact] + public void LayoutGraph_AddEdge_ChildScope_AllowsEdgeIdReuseAcrossScopes() + { + // Arrange: two sibling containers, each with two nodes to connect + var graph = new LayoutGraph(); + var left = graph.AddNode("left", 100, 100); + var right = graph.AddNode("right", 100, 100); + var la = left.Children.AddNode("a", 40, 40); + var lb = left.Children.AddNode("b", 40, 40); + var ra = right.Children.AddNode("a", 40, 40); + var rb = right.Children.AddNode("b", 40, 40); + + // Act: add an edge with the same id "e" inside each container scope + var leftEdge = left.Children.AddEdge("e", la, lb); + var rightEdge = right.Children.AddEdge("e", ra, rb); + + // Assert: both are accepted as distinct edges in their own scopes + Assert.Same(la, leftEdge.Source); + Assert.Same(rb, rightEdge.Target); + Assert.NotSame(leftEdge, rightEdge); + Assert.Single(left.Children.Edges); + Assert.Single(right.Children.Edges); + } + + /// + /// Proves that a cross-container edge between nodes living in two different sibling + /// containers is constructible at their lowest common ancestor (the root). + /// + [Fact] + public void LayoutGraphEdge_CrossContainer_BetweenSiblingContainers_ConstructibleAtRoot() + { + // Arrange: two sibling containers, each holding one descendant node + var graph = new LayoutGraph(); + var groupA = graph.AddNode("groupA", 120, 100); + var groupB = graph.AddNode("groupB", 120, 100); + var innerA = groupA.Children.AddNode("innerA", 40, 40); + var innerB = groupB.Children.AddNode("innerB", 40, 40); + + // Act: add an edge at the root (the LCA of the two siblings) spanning both containers + var cross = graph.AddEdge("sibling-cross", innerA, innerB); + + // Assert: the edge lives at the root yet references descendants of different siblings + Assert.Single(graph.Edges); + Assert.Same(innerA, cross.Source); + Assert.Same(innerB, cross.Target); + Assert.DoesNotContain(innerA, graph.Nodes); + Assert.DoesNotContain(innerB, graph.Nodes); + } +} diff --git a/test/DemaConsulting.Rendering.Tests/LayoutTests.cs b/test/DemaConsulting.Rendering.Tests/LayoutTests.cs new file mode 100644 index 0000000..b213b97 --- /dev/null +++ b/test/DemaConsulting.Rendering.Tests/LayoutTests.cs @@ -0,0 +1,360 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; + +namespace DemaConsulting.Rendering.Tests; + +/// +/// Tests for Layout subsystem node types. +/// +public sealed class LayoutTests +{ + /// + /// A LayoutTree constructed with explicit dimensions and a non-empty Nodes list stores + /// all three constructor arguments without modification. + /// + [Fact] + public void LayoutTree_Construction_StoresWidthHeightNodes() + { + // Arrange: a minimal LayoutBox as the single top-level node + var node = new LayoutBox(0, 0, 10, 10, null, 0, BoxShape.Rectangle, [], []); + + // Act: construct the tree with explicit canvas dimensions + var tree = new LayoutTree(800.0, 600.0, [node]); + + // Assert: all three properties equal the supplied values + Assert.Equal(800.0, tree.Width); + Assert.Equal(600.0, tree.Height); + Assert.Single(tree.Nodes); + Assert.Same(node, tree.Nodes[0]); + } + + /// + /// A LayoutBox constructed with all nine parameters set to non-default values stores + /// each property exactly as supplied. + /// + [Fact] + public void LayoutBox_Construction_StoresAllFields() + { + // Arrange: a compartment and a child node + var compartment = new LayoutCompartment("Attributes", ["radius : Real"]); + var child = new LayoutPort(15, 25, PortSide.Top, "p"); + + // Act: construct a LayoutBox with all nine parameters non-default + var box = new LayoutBox( + X: 10.0, + Y: 20.0, + Width: 200.0, + Height: 100.0, + Label: "MyBlock", + Depth: 2, + Shape: BoxShape.RoundedRectangle, + Compartments: [compartment], + Children: [child]); + + // Assert: all nine properties equal the supplied values + Assert.Equal(10.0, box.X); + Assert.Equal(20.0, box.Y); + Assert.Equal(200.0, box.Width); + Assert.Equal(100.0, box.Height); + Assert.Equal("MyBlock", box.Label); + Assert.Equal(2, box.Depth); + Assert.Equal(BoxShape.RoundedRectangle, box.Shape); + Assert.Single(box.Compartments); + Assert.Same(compartment, box.Compartments[0]); + Assert.Single(box.Children); + Assert.Same(child, box.Children[0]); + } + + /// + /// LayoutBox.Depth is stored as an integer with the supplied value, confirming the + /// depth-not-color invariant: no color property is present on the node. + /// + [Fact] + public void LayoutBox_Depth_IsInteger() + { + // Arrange / Act: construct a LayoutBox with Depth = 3 + var box = new LayoutBox(0, 0, 50, 50, null, 3, BoxShape.Rectangle, [], []); + + // Assert: Depth is stored as int with value 3 + Assert.IsType(box.Depth); + Assert.Equal(3, box.Depth); + } + + /// + /// A LayoutBox constructed with explicit X and Y values stores those values without + /// applying any coordinate transform, confirming absolute-coordinate invariant. + /// + [Fact] + public void LayoutBox_Coordinates_AreAbsolute() + { + // Arrange / Act: construct a box at an arbitrary absolute position + var box = new LayoutBox(100.0, 200.0, 50, 50, null, 0, BoxShape.Rectangle, [], []); + + // Assert: X and Y are stored without offset + Assert.Equal(100.0, box.X); + Assert.Equal(200.0, box.Y); + } + + /// + /// LayoutBox.Children can contain heterogeneous LayoutNode instances of different + /// concrete types, retrievable in insertion order. + /// + [Fact] + public void LayoutBox_Children_ContainsNestedNodes() + { + // Arrange: one LayoutPort and one nested LayoutBox as children + var port = new LayoutPort(10, 10, PortSide.Left, null); + var nested = new LayoutBox(5, 5, 20, 20, null, 1, BoxShape.Rectangle, [], []); + + // Act: construct the parent box with both children + var parent = new LayoutBox(0, 0, 100, 100, null, 0, BoxShape.Rectangle, [], [port, nested]); + + // Assert: both child nodes are retrievable in insertion order + Assert.Equal(2, parent.Children.Count); + Assert.Same(port, parent.Children[0]); + Assert.Same(nested, parent.Children[1]); + } + + /// + /// A LayoutPort constructed with all four parameters stores each property as supplied, + /// confirming that ports carry sufficient information for absolute positioning. + /// + [Fact] + public void LayoutPort_Construction_StoresAllFields() + { + // Arrange / Act: construct a port with all four parameters set + var port = new LayoutPort(250.0, 150.0, PortSide.Right, "myPort"); + + // Assert: all four properties equal the supplied values + Assert.Equal(250.0, port.CentreX); + Assert.Equal(150.0, port.CentreY); + Assert.Equal(PortSide.Right, port.Side); + Assert.Equal("myPort", port.Label); + } + + /// + /// A LayoutPort constructed with explicit CentreX and CentreY stores those values + /// without offset, confirming the absolute-coordinate invariant. + /// + [Fact] + public void LayoutPort_Coordinates_AreAbsolute() + { + // Arrange / Act: construct a port at explicit absolute coordinates + var port = new LayoutPort(250.0, 150.0, PortSide.Top, null); + + // Assert: coordinates are stored without transformation + Assert.Equal(250.0, port.CentreX); + Assert.Equal(150.0, port.CentreY); + } + + /// + /// A LayoutLine constructed with all five parameters set stores each property as + /// supplied, including the full waypoints list, arrowhead styles, line style, and label. + /// + [Fact] + public void LayoutLine_Construction_StoresAllFields() + { + // Arrange: a two-element waypoints list and a midpoint label + var p1 = new Point2D(10.0, 20.0); + var p2 = new Point2D(200.0, 300.0); + + // Act: construct a LayoutLine with all five parameters non-default + var line = new LayoutLine( + [p1, p2], + EndMarkerStyle.HollowTriangle, + EndMarkerStyle.FilledArrow, + LineStyle.Dashed, + "myLabel"); + + // Assert: all five properties equal the supplied values + Assert.Equal(2, line.Waypoints.Count); + Assert.Same(p1, line.Waypoints[0]); + Assert.Same(p2, line.Waypoints[1]); + Assert.Equal(EndMarkerStyle.HollowTriangle, line.SourceEnd); + Assert.Equal(EndMarkerStyle.FilledArrow, line.TargetEnd); + Assert.Equal(LineStyle.Dashed, line.LineStyle); + Assert.Equal("myLabel", line.MidpointLabel); + } + + /// + /// A LayoutLine constructed with explicit waypoints stores those waypoints with their + /// supplied X and Y values intact, confirming that routing produces absolute coordinates. + /// + [Fact] + public void LayoutLine_Waypoints_AreAbsolute() + { + // Arrange: two waypoints at known absolute positions + var p1 = new Point2D(10.0, 20.0); + var p2 = new Point2D(200.0, 300.0); + + // Act: construct the line with these waypoints + var line = new LayoutLine([p1, p2], EndMarkerStyle.None, EndMarkerStyle.None, LineStyle.Solid, null); + + // Assert: both waypoints retain their supplied X and Y values + Assert.Equal(10.0, line.Waypoints[0].X); + Assert.Equal(20.0, line.Waypoints[0].Y); + Assert.Equal(200.0, line.Waypoints[1].X); + Assert.Equal(300.0, line.Waypoints[1].Y); + } + + /// + /// A LayoutLabel constructed with all eight parameters stores each property as supplied. + /// + [Fact] + public void LayoutLabel_Construction_StoresAllFields() + { + // Arrange / Act: construct a label with all eight parameters non-default + var label = new LayoutLabel(50.0, 75.0, 200.0, "Hello World", TextAlign.Center, FontWeight.Regular, FontStyle.Normal, 12.0); + + // Assert: all eight properties equal the supplied values + Assert.Equal(50.0, label.X); + Assert.Equal(75.0, label.Y); + Assert.Equal(200.0, label.MaxWidth); + Assert.Equal("Hello World", label.Text); + Assert.Equal(TextAlign.Center, label.Align); + Assert.Equal(FontWeight.Regular, label.Weight); + Assert.Equal(FontStyle.Normal, label.Style); + Assert.Equal(12.0, label.FontSize); + } + + /// + /// A LayoutBadge constructed with all five parameters stores each property as supplied. + /// + [Fact] + public void LayoutBadge_Construction_StoresAllFields() + { + // Arrange / Act: construct a badge with all five parameters set + var badge = new LayoutBadge(30.0, 40.0, 12.0, BadgeShape.FilledCircle, "initial"); + + // Assert: all five properties equal the supplied values + Assert.Equal(30.0, badge.CentreX); + Assert.Equal(40.0, badge.CentreY); + Assert.Equal(12.0, badge.Size); + Assert.Equal(BadgeShape.FilledCircle, badge.Shape); + Assert.Equal("initial", badge.Label); + } + + /// + /// A LayoutBand constructed with all seven parameters stores each property as supplied. + /// + [Fact] + public void LayoutBand_Construction_StoresAllFields() + { + // Arrange: a child node for the band + var child = new LayoutBox(5, 5, 10, 10, null, 0, BoxShape.Rectangle, [], []); + + // Act: construct a band with all seven parameters set + var band = new LayoutBand(10.0, 20.0, 400.0, 200.0, BandOrientation.Horizontal, "Lane 1", [child]); + + // Assert: all seven properties equal the supplied values + Assert.Equal(10.0, band.X); + Assert.Equal(20.0, band.Y); + Assert.Equal(400.0, band.Width); + Assert.Equal(200.0, band.Height); + Assert.Equal(BandOrientation.Horizontal, band.Orientation); + Assert.Equal("Lane 1", band.Label); + Assert.Single(band.Children); + Assert.Same(child, band.Children[0]); + } + + /// + /// A LayoutLifeline constructed with all six parameters stores each property as supplied. + /// + [Fact] + public void LayoutLifeline_Construction_StoresAllFields() + { + // Arrange / Act: construct a lifeline with all six parameters set + var lifeline = new LayoutLifeline(150.0, 10.0, 500.0, ":Actor", 80.0, 40.0); + + // Assert: all six properties equal the supplied values + Assert.Equal(150.0, lifeline.CentreX); + Assert.Equal(10.0, lifeline.TopY); + Assert.Equal(500.0, lifeline.BottomY); + Assert.Equal(":Actor", lifeline.Label); + Assert.Equal(80.0, lifeline.HeaderWidth); + Assert.Equal(40.0, lifeline.HeaderHeight); + } + + /// + /// A LayoutActivation constructed with CentreX, TopY, and BottomY stores all three + /// properties as supplied. + /// + [Fact] + public void LayoutActivation_Construction_StoresAllFields() + { + // Arrange / Act: construct an activation bar with explicit coordinates + var activation = new LayoutActivation(150.0, 100.0, 250.0); + + // Assert: all three properties equal the supplied values + Assert.Equal(150.0, activation.CentreX); + Assert.Equal(100.0, activation.TopY); + Assert.Equal(250.0, activation.BottomY); + } + + /// + /// A LayoutGrid constructed with X, Y, and a Rows list stores all fields as supplied, + /// including LayoutGridRow.IsHeader and the LayoutGridCell values. + /// + [Fact] + public void LayoutGrid_Construction_StoresAllFields() + { + // Arrange: one header row containing one cell + var cell = new LayoutGridCell(80.0, 24.0, "Name", TextAlign.Left, 1); + var row = new LayoutGridRow(IsHeader: true, Cells: [cell]); + + // Act: construct the grid at an explicit absolute position + var grid = new LayoutGrid(X: 50.0, Y: 100.0, Rows: [row]); + + // Assert: grid-level properties + Assert.Equal(50.0, grid.X); + Assert.Equal(100.0, grid.Y); + Assert.Single(grid.Rows); + + // Assert: row-level properties + Assert.True(grid.Rows[0].IsHeader); + Assert.Single(grid.Rows[0].Cells); + + // Assert: cell-level properties + var storedCell = grid.Rows[0].Cells[0]; + Assert.Equal(80.0, storedCell.Width); + Assert.Equal(24.0, storedCell.Height); + Assert.Equal("Name", storedCell.Text); + Assert.Equal(TextAlign.Left, storedCell.Align); + Assert.Equal(1, storedCell.ColSpan); + } + + /// + /// A Point2D constructed with explicit coordinates stores its X and Y unchanged, confirming + /// the shared point value type carries absolute coordinates verbatim. + /// + [Fact] + public void Point2D_Construction_StoresXY() + { + // Arrange / Act: construct a point at a known location + var point = new Point2D(12.5, -7.25); + + // Assert: both coordinates equal the supplied values + Assert.Equal(12.5, point.X); + Assert.Equal(-7.25, point.Y); + } + + /// + /// A Rect constructed with explicit position and size stores all four fields unchanged, + /// confirming the shared axis-aligned rectangle value type carries bounds verbatim. + /// + [Fact] + public void Rect_Construction_StoresAllFields() + { + // Arrange / Act: construct a rectangle with all four fields non-default + var rect = new Rect(10.0, 20.0, 300.0, 150.0); + + // Assert: all four fields equal the supplied values + Assert.Equal(10.0, rect.X); + Assert.Equal(20.0, rect.Y); + Assert.Equal(300.0, rect.Width); + Assert.Equal(150.0, rect.Height); + } +} diff --git a/test/DemaConsulting.Rendering.Tests/PropertyHolderTests.cs b/test/DemaConsulting.Rendering.Tests/PropertyHolderTests.cs new file mode 100644 index 0000000..1d3cd6d --- /dev/null +++ b/test/DemaConsulting.Rendering.Tests/PropertyHolderTests.cs @@ -0,0 +1,176 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Tests; + +/// +/// Tests for the ELK-style open property system ( and +/// ). +/// +public class PropertyHolderTests +{ + private static readonly LayoutProperty Count = new("test.count", 7); + private static readonly LayoutProperty Name = new("test.name", "default"); + + /// + /// Proves that reading an unset property returns the property's declared default. + /// + [Fact] + public void Get_UnsetProperty_ReturnsDefault() + { + // Arrange: create an empty holder + var holder = new PropertyHolder(); + + // Act / Assert: an unset property yields its declared default + Assert.Equal(7, holder.Get(Count)); + } + + /// + /// Proves that a value written with Set is returned by a subsequent Get. + /// + [Fact] + public void Get_AfterSet_ReturnsStoredValue() + { + // Arrange: create an empty holder + var holder = new PropertyHolder(); + + // Act: store a value + holder.Set(Count, 42); + + // Assert: the stored value is returned + Assert.Equal(42, holder.Get(Count)); + } + + /// + /// Proves that Contains reflects whether a property has been explicitly set. + /// + [Fact] + public void Contains_ReflectsExplicitSet() + { + // Arrange: create an empty holder + var holder = new PropertyHolder(); + + // Act / Assert: Contains flips from false to true once the property is set + Assert.False(holder.Contains(Count)); + holder.Set(Count, 1); + Assert.True(holder.Contains(Count)); + } + + /// + /// Proves that TryGet reports false and yields the default for an unset property. + /// + [Fact] + public void TryGet_UnsetProperty_ReturnsFalseAndDefault() + { + // Arrange: create an empty holder + var holder = new PropertyHolder(); + + // Act: attempt to read an unset property + var found = holder.TryGet(Count, out var value); + + // Assert: the read reports failure and returns the default + Assert.False(found); + Assert.Equal(7, value); + } + + /// + /// Proves that explicitly setting a reference-type property to null is honored by Get, + /// returning null rather than the property's non-null default. + /// + [Fact] + public void Get_AfterSetNull_ReturnsNull() + { + // Arrange: create a holder and explicitly clear a property to null + var holder = new PropertyHolder(); + holder.Set(Name, null); + + // Act / Assert: the explicit null is returned, not the "default" fallback + Assert.Null(holder.Get(Name)); + } + + /// + /// Proves that TryGet treats an explicitly-stored null as present, reporting true with a + /// null value, so it stays consistent with Contains. + /// + [Fact] + public void TryGet_AfterSetNull_ReturnsTrueAndNull() + { + // Arrange: create a holder and explicitly clear a property to null + var holder = new PropertyHolder(); + holder.Set(Name, null); + + // Act: read the property back + var found = holder.TryGet(Name, out var value); + + // Assert: the null is reported as present + Assert.True(found); + Assert.Null(value); + } + + /// + /// Proves that Contains reports true for a property explicitly set to null, matching TryGet. + /// + [Fact] + public void Contains_AfterSetNull_ReturnsTrue() + { + // Arrange: create a holder and explicitly clear a property to null + var holder = new PropertyHolder(); + holder.Set(Name, null); + + // Act / Assert: the key is considered present + Assert.True(holder.Contains(Name)); + } + + /// + /// Proves that Get rejects a null property argument. + /// + [Fact] + public void Get_NullProperty_ThrowsArgumentNullException() + { + // Arrange: create an empty holder + var holder = new PropertyHolder(); + + // Act / Assert: a null property argument is rejected + Assert.Throws(() => holder.Get(null!)); + } + + /// + /// Proves that TryGet rejects a null property argument. + /// + [Fact] + public void TryGet_NullProperty_ThrowsArgumentNullException() + { + // Arrange: create an empty holder + var holder = new PropertyHolder(); + + // Act / Assert: a null property argument is rejected + Assert.Throws(() => holder.TryGet(null!, out _)); + } + + /// + /// Proves that Set rejects a null property argument. + /// + [Fact] + public void Set_NullProperty_ThrowsArgumentNullException() + { + // Arrange: create an empty holder + var holder = new PropertyHolder(); + + // Act / Assert: a null property argument is rejected + Assert.Throws(() => holder.Set(null!, 1)); + } + + /// + /// Proves that Contains rejects a null property argument. + /// + [Fact] + public void Contains_NullProperty_ThrowsArgumentNullException() + { + // Arrange: create an empty holder + var holder = new PropertyHolder(); + + // Act / Assert: a null property argument is rejected + Assert.Throws(() => holder.Contains(null!)); + } +} diff --git a/test/DemaConsulting.Rendering.Tests/RenderingTests.cs b/test/DemaConsulting.Rendering.Tests/RenderingTests.cs deleted file mode 100644 index 50a3f73..0000000 --- a/test/DemaConsulting.Rendering.Tests/RenderingTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using DemaConsulting.Rendering; - -namespace DemaConsulting.Rendering.Tests; - -/// -/// System-level integration tests for the Rendering system. -/// -public class RenderingTests -{ - /// - /// Proves that the system can be instantiated and provides expected functionality - /// when integrated with all components. - /// - [Fact] - public void Rendering_SystemIntegration_DefaultConstruction_ReturnsExpectedGreeting() - { - // Arrange: set up system-level integration test - var demo = new Demo(); - const string testName = "System"; - - // Act: exercise system functionality end-to-end - var result = demo.DemoMethod(testName); - - // Assert: system produces expected integrated behavior - Assert.Equal("Hello, System!", result); - } - - /// - /// Proves that the system handles configuration and customization properly - /// across all integrated components. - /// - [Fact] - public void Rendering_SystemCustomization_CustomPrefix_ReturnsExpectedGreeting() - { - // Arrange: set up system with custom configuration - const string customPrefix = "Welcome"; - var demo = new Demo(customPrefix); - const string testName = "Integration"; - - // Act: exercise system with custom configuration - var result = demo.DemoMethod(testName); - - // Assert: system respects configuration across components - Assert.Equal("Welcome, Integration!", result); - } - - /// - /// Proves that the system rejects null input passed to DemoMethod - /// with the expected exception at the system level. - /// - [Fact] - public void Rendering_SystemValidation_DemoMethodNullInput_ThrowsArgumentNullException() - { - // Arrange: set up system components - var demo = new Demo(); - - // Act & Assert: system validates DemoMethod null input properly - Assert.Throws(() => demo.DemoMethod(null!)); - } - - /// - /// Proves that the system rejects empty input passed to DemoMethod - /// with the expected exception at the system level. - /// - [Fact] - public void Rendering_SystemValidation_DemoMethodEmptyInput_ThrowsArgumentException() - { - // Arrange: set up system components - var demo = new Demo(); - - // Act & Assert: system validates DemoMethod empty input properly - Assert.Throws(() => demo.DemoMethod(string.Empty)); - } - - /// - /// Proves that the system rejects a null constructor prefix - /// with the expected exception at the system level. - /// - [Fact] - public void Rendering_SystemValidation_ConstructorNullPrefix_ThrowsArgumentNullException() - { - // Act & Assert: system validates constructor null prefix properly - Assert.Throws(() => new Demo(null!)); - } - - /// - /// Proves that the system rejects an empty constructor prefix - /// with the expected exception at the system level. - /// - [Fact] - public void Rendering_SystemValidation_ConstructorEmptyPrefix_ThrowsArgumentException() - { - // Act & Assert: system validates constructor empty prefix properly - Assert.Throws(() => new Demo(string.Empty)); - } - - /// - /// Proves that the Prefix property exposes the configured prefix at the system level. - /// - [Fact] - public void Rendering_SystemIntegration_CustomPrefix_ExposesPrefix() - { - // Arrange: construct system with a custom prefix - const string customPrefix = "Greetings"; - var demo = new Demo(customPrefix); - - // Act: read the Prefix property through the public API - var prefix = demo.Prefix; - - // Assert: the system exposes the configured prefix correctly - Assert.Equal(customPrefix, prefix); - } -}