diff --git a/.cspell.yaml b/.cspell.yaml index c1d0b72f..b2e7ec06 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -30,6 +30,8 @@ words: - docversion - errorlevel - fileassert + - Fruchterman + - Hanan - KEBNF - MBSE - xunit @@ -38,12 +40,16 @@ words: - Noto - Pandoc - Qube + - Quadcopter + - Reingold - reqstream - reviewmark - SARIF - sarifmark - setlocal - Skia + - Subsetting + - Sugiyama - SkiaSharp - snupkg - sonarmark diff --git a/.reviewmark.yaml b/.reviewmark.yaml index b13cf491..fe8ec402 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -373,6 +373,252 @@ reviews: - "docs/verification/sysml2-tools-core.md" - "docs/verification/sysml2-tools-core/rendering.md" + # === Layout Engine subsystem and units (rendering subsystem implementation reviews) === + - id: SysML2Tools-Core-Layout-Engine + title: Review that DemaConsulting.SysML2Tools Layout Engine Subsystem is Consistent and Complete + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + paths: + - "docs/reqstream/sysml2-tools-core/layout/engine.yaml" + - "docs/design/sysml2-tools-core/layout/engine.md" + - "docs/verification/sysml2-tools-core/layout/engine.md" + + - id: SysML2Tools-Core-Layout-Engine-ChannelRouter + title: Review that DemaConsulting.SysML2Tools Layout Engine ChannelRouter Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/engine.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/engine/channel-router.yaml" + - "docs/design/sysml2-tools-core/layout/engine/channel-router.md" + - "docs/verification/sysml2-tools-core/layout/engine/channel-router.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs" + + - id: SysML2Tools-Core-Layout-Engine-ForceDirectedEngine + title: Review that DemaConsulting.SysML2Tools Layout Engine ForceDirectedEngine Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/engine.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/engine/force-directed-engine.yaml" + - "docs/design/sysml2-tools-core/layout/engine/force-directed-engine.md" + - "docs/verification/sysml2-tools-core/layout/engine/force-directed-engine.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ForceDirectedEngine.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ForceDirectedEngineTests.cs" + + - id: SysML2Tools-Core-Layout-Engine-PortAssigner + title: Review that DemaConsulting.SysML2Tools Layout Engine PortAssigner Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/engine.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/engine/port-assigner.yaml" + - "docs/design/sysml2-tools-core/layout/engine/port-assigner.md" + - "docs/verification/sysml2-tools-core/layout/engine/port-assigner.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Engine/PortAssigner.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/PortAssignerTests.cs" + + - id: SysML2Tools-Core-Layout-Engine-LayeredLayoutEngine + title: Review that DemaConsulting.SysML2Tools Layout Engine LayeredLayoutEngine Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/engine.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/engine/layered-layout-engine.yaml" + - "docs/design/sysml2-tools-core/layout/engine/layered-layout-engine.md" + - "docs/verification/sysml2-tools-core/layout/engine/layered-layout-engine.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/LayeredLayoutEngineTests.cs" + + - id: SysML2Tools-Core-Layout-Engine-ContainmentPacker + title: Review that DemaConsulting.SysML2Tools Layout Engine ContainmentPacker Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/engine.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/engine/containment-packer.yaml" + - "docs/design/sysml2-tools-core/layout/engine/containment-packer.md" + - "docs/verification/sysml2-tools-core/layout/engine/containment-packer.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ContainmentPacker.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ContainmentPackerTests.cs" + + # === Layout Internal subsystem and view-strategy units === + - id: SysML2Tools-Core-Layout-Internal + title: Review that DemaConsulting.SysML2Tools Layout Internal Subsystem is Consistent and Complete + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal.yaml" + - "docs/design/sysml2-tools-core/layout/internal.md" + - "docs/verification/sysml2-tools-core/layout/internal.md" + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy + title: Review that DemaConsulting.SysML2Tools Layout Internal GeneralViewLayoutStrategy Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml" + - "docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md" + - "docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs" + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy + title: >- + Review that DemaConsulting.SysML2Tools Layout Internal + InterconnectionViewLayoutStrategy Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml" + - "docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md" + - "docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs" + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy + title: >- + Review that DemaConsulting.SysML2Tools Layout Internal + StateTransitionViewLayoutStrategy Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml" + - "docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md" + - "docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs" + + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy + title: Review that DemaConsulting.SysML2Tools Layout Internal ActionFlowViewLayoutStrategy Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml" + - "docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md" + - "docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs" + + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy + title: Review that DemaConsulting.SysML2Tools Layout Internal SequenceViewLayoutStrategy Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml" + - "docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md" + - "docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs" + + - id: SysML2Tools-Core-Layout-Internal-GridViewLayoutStrategy + title: Review that DemaConsulting.SysML2Tools Layout Internal GridViewLayoutStrategy Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml" + - "docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md" + - "docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs" + + - id: SysML2Tools-Core-Layout-Internal-BrowserViewLayoutStrategy + title: Review that DemaConsulting.SysML2Tools Layout Internal BrowserViewLayoutStrategy Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml" + - "docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md" + - "docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs" + + - id: SysML2Tools-Core-Layout-Internal-LayoutWarnings + title: Review that DemaConsulting.SysML2Tools Layout Internal LayoutWarnings Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml" + - "docs/design/sysml2-tools-core/layout/internal/layout-warnings.md" + - "docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs" + + - id: SysML2Tools-Core-Layout-ConnectorLabelPlacer + title: Review that DemaConsulting.SysML2Tools Layout ConnectorLabelPlacer Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/connector-label-placer.yaml" + - "docs/design/sysml2-tools-core/layout/connector-label-placer.md" + - "docs/verification/sysml2-tools-core/layout/connector-label-placer.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/ConnectorLabelPlacer.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/ConnectorLabelPlacerTests.cs" + + # === Rendering Internal subsystem and units === + - id: SysML2Tools-Core-Rendering-Internal + title: Review that DemaConsulting.SysML2Tools Rendering Internal Subsystem is Consistent and Complete + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + paths: + - "docs/reqstream/sysml2-tools-core/rendering/internal.yaml" + - "docs/design/sysml2-tools-core/rendering/internal.md" + - "docs/verification/sysml2-tools-core/rendering/internal.md" + + - id: SysML2Tools-Core-Rendering-Internal-DiagramTypeRouter + title: Review that DemaConsulting.SysML2Tools Rendering Internal DiagramTypeRouter Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/rendering.md + - docs/design/sysml2-tools-core/rendering/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/rendering/internal/diagram-type-router.yaml" + - "docs/design/sysml2-tools-core/rendering/internal/diagram-type-router.md" + - "docs/verification/sysml2-tools-core/rendering/internal/diagram-type-router.md" + - "src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs" + # SysML2Tools SVG Renderer - id: SysML2Tools-Svg-Architecture title: Review that DemaConsulting.SysML2Tools.Svg Architecture Satisfies Requirements diff --git a/ROADMAP.md b/ROADMAP.md index 32a70a05..3d883382 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,14 +19,14 @@ onward) needed to reach full SysML v2 view coverage. | # | View Type | Purpose | Our Status | |---|-----------|---------|------------| -| 1 | **General View** | Any model element; foundational "catch-all" view | 🟑 Partial | -| 2 | **Interconnection View** | Structural contents of a Usage (parts, ports, connectors) | πŸ”΄ Unsupported | -| 3 | **Action Flow View** | Input/output flows between actions (behavioral dynamics) | πŸ”΄ Unsupported | -| 4 | **State Transition View** | States and transitions (behavioral dynamics) | πŸ”΄ Unsupported | -| 5 | **Sequence View** | Chronological event occurrences on lifelines | πŸ”΄ Unsupported | -| 6 | **Grid View** | Elements in structured rectangular grid (tabular/matrix) | πŸ”΄ Unsupported | -| 7 | **Browser View** | Hierarchical membership structure from a root element | πŸ”΄ Unsupported | -| 8 | **Geometry View** | Spatial items in 2D or 3D | πŸ”΄ Unsupported | +| 1 | **General View** | Any model element; foundational "catch-all" view | 🟒 Implemented | +| 2 | **Interconnection View** | Structural contents of a Usage (parts, ports, connectors) | 🟒 Implemented | +| 3 | **Action Flow View** | Input/output flows between actions (behavioral dynamics) | 🟒 Implemented | +| 4 | **State Transition View** | States and transitions (behavioral dynamics) | 🟒 Implemented | +| 5 | **Sequence View** | Chronological event occurrences on lifelines | 🟒 Implemented | +| 6 | **Grid View** | Elements in structured rectangular grid (tabular/matrix) | 🟒 Implemented | +| 7 | **Browser View** | Hierarchical membership structure from a root element | 🟒 Implemented | +| 8 | **Geometry View** | Spatial items in 2D or 3D | πŸ”΄ Unsupported (deferred) | --- @@ -273,11 +273,11 @@ Grid, Browser, Geometry) require no engine β€” pure arithmetic in the strategy c | Engine | Category | First Used In | Reused In | |--------|----------|---------------|-----------| -| `ContainmentPacker` | Bottom-up size, bin packing | Phase 6 | Phase 7, 8 | -| `ChannelRouter` | Orthogonal edge routing around obstacles | Phase 6 | Phase 7, 8 | -| `ForceDirectedEngine` | Fruchterman-Reingold spring layout | Phase 8 | Phase 9 | -| `PortAssigner` | Port-side and slot heuristic | Phase 8 | β€” | -| `LayeredLayoutEngine` | Simplified Sugiyama DAG layout | Phase 10 | β€” | +| `ContainmentPacker` βœ… | Bottom-up size, bin packing | Phase 6 | Phase 7, 8 | +| `ChannelRouter` βœ… | Orthogonal edge routing around obstacles | Phase 6 | Phase 7, 8 | +| `ForceDirectedEngine` βœ… | Fruchterman-Reingold spring layout | Phase 8 | Phase 9 | +| `PortAssigner` βœ… | Port-side and slot heuristic | Phase 8 | β€” | +| `LayeredLayoutEngine` βœ… | Simplified Sugiyama DAG layout | Phase 10 | β€” | All engines live in `Layout/Engine/` and have their own unit tests with **synthetic inputs** (no parser or view code required). Integration is validated through the @@ -285,7 +285,13 @@ existing render integration tests. --- -### Phase 6 β€” General View: All Definitions + Edges + ContainmentPacker + ChannelRouter (2–3 sessions) +### Phase 6 β€” General View: All Definitions + Edges + ContainmentPacker + ChannelRouter (2–3 sessions) β€” βœ… COMPLETE + +> **Status:** Complete. All definition kinds render with keyword labels; packages render as +> folder-tab containers; `ContainmentPacker` and `ChannelRouter` engines implemented and +> unit-tested; specialization edges route around boxes. Standard-library filtering switched from +> a fixed prefix list to seed-origin tracking (`SysmlWorkspace.StdlibNames`). Visual gate passed +> against `2a-PartsInterconnection`, `1a-PartsTree`, and `nested-packages-with-view`. Highest-value incremental improvement: complete the General View to show all Definition types and relationship edges, and simultaneously introduce the two layout @@ -347,7 +353,20 @@ Agent views each PNG and asserts: --- -### Phase 7 β€” General View: Usage Nodes, Compartments + Annotating Elements (2–3 sessions) +### Phase 7 β€” General View: Usage Nodes, Compartments + Annotating Elements (2–3 sessions) β€” βœ… COMPLETE (compartment style) + +> **Status:** Complete. Definitions now render their owned usages as keyword-grouped compartments +> (e.g. *attributes*, *ports*, *parts*) with `name : Type [n]` rows. `SysmlFeatureNode` gained +> `FeatureKeyword`, `FeatureTyping`, and `Multiplicity`; `AstBuilder` visits part/port/attribute/ +> item/reference/enum/occurrence usages and extracts the type from both the `typed by` clause and +> the typing list. Compartment row spacing improved in both renderers. +> +> **Design decision:** In the General View, usages render as *compartment rows* (matching the SysON +> General View compartment style) rather than nested rounded boxes. Nested-box containment with +> ports and connectors is the defining purpose of the **Interconnection View (Phase 8)** and is +> implemented there to avoid duplicating containment layout. Documentation/Comment note-shape nodes +> are deferred β€” the `BoxShape.Note` primitive is implemented and ready, but annotating-element AST +> capture is left to a follow-up. Visual gate passed against `vehicle-with-usages`. Extend the semantic model and layout to capture Usage (feature) elements and render compartments and annotation nodes. No new engines required. @@ -395,7 +414,17 @@ Agent views each PNG and asserts: --- -### Phase 8 β€” Interconnection View + ForceDirectedEngine + PortAssigner (2–3 sessions) +### Phase 8 β€” Interconnection View + ForceDirectedEngine + PortAssigner (2–3 sessions) β€” βœ… COMPLETE + +> **Status:** Complete. `ForceDirectedEngine` (deterministic Fruchterman-Reingold with overlap +> removal) and `PortAssigner` (side selection + even slot distribution) implemented and unit-tested. +> `InterconnectionViewLayoutStrategy` renders a part definition's interior: nested part usages as +> rounded boxes placed by the force engine, ports on box boundaries via `PortAssigner`, and +> connection usages routed as orthogonal connectors via `ChannelRouter`. `AstBuilder` captures +> connection usages with both endpoints (`SysmlConnectionNode`). `DiagramTypeRouter` dispatches to +> the interconnection strategy when a view's name or supertype contains "Interconnection". +> Visual gate passed against `power-system-interconnection` (drivetrain chain with port-to-port +> connectors, no overlaps). Implement the Interconnection View, introducing two new engines that will also be reused by the State Transition View in Phase 9. @@ -455,7 +484,20 @@ Agent views each PNG and asserts: --- -### Phase 9 β€” State Transition View + Bezier Routing (2–3 sessions) +### Phase 9 β€” State Transition View + Bezier Routing (2–3 sessions) β€” βœ… COMPLETE (orthogonal routing) + +> **Status:** Complete. `SysmlTransitionNode` captures transition source/target/guard; +> `AstBuilder` visits state usages (`VisitStateUsage`) and transitions (`VisitTransitionUsage`), +> and `VisitStateDefinition` now collects the state-def body (states + transitions) via a generic +> `CollectChildren` helper. `StateTransitionViewLayoutStrategy` places states with the +> force-directed engine, draws an initial pseudo-state (filled circle) into the first declared +> state, and renders transitions with filled arrowheads and `[guard]` midpoint labels; self- +> transitions render as a small loop. `DiagramTypeRouter` dispatches on "StateTransition"/"State". +> +> **Design decision:** Transitions use orthogonal routing via `ChannelRouter` rather than Bezier +> curves β€” orthogonal state diagrams are clear and reuse the existing routing engine. Bezier +> curve rendering remains a possible future enhancement. Visual gate passed against +> `traffic-light-states` (three states, initial marker, guarded transitions, no overlaps). Implement the State Transition View. Reuses `ForceDirectedEngine` from Phase 8; adds curved/Bezier edge routing for the general-graph topology. @@ -507,7 +549,20 @@ Agent views the PNG and asserts: --- -### Phase 10 β€” Action Flow View + LayeredLayoutEngine (2–3 sessions) +### Phase 10 β€” Action Flow View + LayeredLayoutEngine (2–3 sessions) β€” βœ… COMPLETE (orthogonal flows) + +> **Status:** Complete. `LayeredLayoutEngine` (simplified Sugiyama: DFS cycle removal, longest-path +> layer assignment, barycenter crossing-reduction sweeps, coordinate assignment) implemented and +> unit-tested (5 tests: layer ordering, downward edges, no same-layer overlap, cycle handling). +> `AstBuilder` captures action usages (`VisitActionUsage`) and successions (`VisitSuccessionAsUsage` +> β†’ `SysmlTransitionNode`); `VisitActionDefinition` collects the action body. +> `ActionFlowViewLayoutStrategy` lays actions out top-to-bottom in layers, adds a start node +> (filled circle) into the initial actions and a done node (bullseye) from the final actions, and +> routes successions as flow arrows. `DiagramTypeRouter` dispatches on "ActionFlow"/"Action". +> +> **Design decision:** Decision/fork/join nodes render as regular action boxes (branch points); +> dedicated diamond/bar shapes for decision and fork/join detection are a future enhancement. +> Visual gate passed against `order-action-flow` (branch + join, correct layering, no overlaps). Implement the Action Flow View, introducing the Sugiyama-style layered layout engine. @@ -567,7 +622,18 @@ Agent views the PNG and asserts: --- -### Phase 11 β€” Sequence View (1–2 sessions) +### Phase 11 β€” Sequence View (1–2 sessions) β€” βœ… COMPLETE (core) + +> **Status:** Complete. `AstBuilder.VisitMessage` captures message usages (name + from/to event +> references) as `SysmlConnectionNode` with keyword "message". `SequenceViewLayoutStrategy` renders +> the participating lifelines (distinct first-segment participants) as dashed stems with header +> boxes and draws each message as a horizontal arrow between lifelines, ordered top-to-bottom by +> declaration order, with the message name as the arrow label; self-messages render as a small loop. +> `DiagramTypeRouter` dispatches on "Sequence". +> +> **Deferred enhancements:** Activation bars and combined fragments (alt/loop/opt) β€” the +> `LayoutActivation` primitive is implemented and ready. Visual gate passed against +> `client-server-sequence` (two lifelines, three ordered messages with correct arrow directions). Implement the Sequence View. No new engines β€” pure column-and-time-axis arithmetic. @@ -617,7 +683,16 @@ Agent views the PNG and asserts: --- -### Phase 12 β€” Grid View + Browser View (1–2 sessions) +### Phase 12 β€” Grid View + Browser View (1–2 sessions) β€” βœ… COMPLETE + +> **Status:** Complete. Both views are pure-arithmetic strategies (no new engine, no new AST). +> `BrowserViewLayoutStrategy` builds the membership tree from the qualified-name hierarchy of +> non-stdlib declarations and renders indented rows with parentβ†’child connector lines. +> `GridViewLayoutStrategy` renders a specialization relationship matrix (definitions Γ— definitions, +> marked where the row specializes the column) via `LayoutGrid` with styled header row/column. +> `DiagramTypeRouter` dispatches on "Browser"/"Tree" and "Grid"/"Matrix"/"Tabular". +> Visual gate passed against `catalog-browser-grid` (indented tree; specialization matrix with +> correct marks). Implement tabular and tree views. No new engines β€” pure geometric arithmetic. @@ -676,18 +751,19 @@ Current coverage: | Primitive | Used By | Status | |---|---|---| -| `LayoutBox` | All views | βœ… Implemented + rendered | -| `LayoutLabel` | All views | βœ… Implemented + rendered | -| `LayoutLine` | General View (edges) | βœ… Implemented + rendered | -| `LayoutCompartment` | General View, Interconnection | βœ… Defined, not yet populated | -| `LayoutPort` | Interconnection View | βœ… Defined, not yet rendered | -| `LayoutLifeline` | Sequence View | βœ… Defined, not yet rendered | -| `LayoutBand` | Action Flow View (swim-lanes) | βœ… Defined, not yet rendered | -| `LayoutBadge` | Annotations / decorators | βœ… Defined, not yet rendered | -| `LayoutGrid` | Grid View, Browser View | βœ… Defined, not yet rendered | - -The vocabulary is complete. No structural breaking changes to `IRenderer` are expected -as new view implementations are added. +| `LayoutBox` | All structural views | βœ… Implemented + rendered | +| `LayoutLabel` | Browser, truncation indicators | βœ… Implemented + rendered | +| `LayoutLine` | All views (edges/arrows) | βœ… Implemented + rendered | +| `LayoutCompartment` | General View | βœ… Populated + rendered (Phase 7) | +| `LayoutPort` | Interconnection View | βœ… Rendered (Phase 8) | +| `LayoutLifeline` | Sequence View | βœ… Rendered (Phase 11) | +| `LayoutBand` | Action Flow swim-lanes | βœ… Defined, not yet populated (future) | +| `LayoutBadge` | State/Action markers | βœ… Rendered (Phases 9–10) | +| `LayoutGrid` | Grid View | βœ… Rendered (Phase 12) | + +The vocabulary is complete and the renderers handle every primitive. `LayoutActivation` (sequence +activation bars) and `LayoutBand` (action swim-lanes) are rendered/available but not yet populated +by their strategies β€” reserved for future refinements. --- @@ -748,17 +824,23 @@ Layout/ | Phase 1 β€” Parser + Stdlib | βœ… Complete | | | Phase 2 β€” Semantic Model | βœ… Complete | | | Phase 3 β€” LayoutTree Design | βœ… Complete | All 8 view primitives defined | -| Phase 4 β€” GeneralView + Renderers | βœ… Complete | `part def` only | +| Phase 4 β€” GeneralView + Renderers | βœ… Complete | All definition kinds + usages (Phases 6–7) | | Phase 5 β€” Polish + Self-test | βœ… Complete | `--validate`, `--auto`, themes | +| Phases 6–7 β€” General View (complete) | βœ… Complete | All definitions, compartments, edges, folder packages | +| Phase 8 β€” Interconnection View | βœ… Complete | Force-directed parts, ports, connectors | +| Phase 9 β€” State Transition View | βœ… Complete | Force-directed states, transitions, initial marker | +| Phase 10 β€” Action Flow View | βœ… Complete | Layered (Sugiyama) actions, start/done markers | +| Phase 11 β€” Sequence View | βœ… Complete | Lifelines + messages (activations deferred) | +| Phase 12 β€” Grid + Browser Views | βœ… Complete | Relationship matrix + membership tree | +| Phase 13 β€” Geometry View | 🟒 Deferred | Requires spatial coordinate data (future) | | Open Concern #1 β€” LayoutTree covers all 8 views | βœ… Resolved | Vocabulary is sufficient | | Open Concern #2 β€” IRenderer API stability | βœ… Stable | No breaking changes needed | | Open Concern #3 β€” SkiaSharp native assets | 🟑 Documented | Needs package README | | Open Concern #4 β€” Noto Sans OFL attribution | 🟑 Pending | Needs `--licenses` output | | Open Concern #5 β€” spec42 competitive risk | 🟒 Low | Unchanged | -| Open Concern #6 β€” Theme file format for v2 | 🟒 Deferred | YAML/JSON, Phase 6+ | +| Open Concern #6 β€” Theme file format for v2 | 🟒 Deferred | YAML/JSON, future | | SARIF output | 🟒 Deferred | Infrastructure ready | -| Loadable theme files | 🟒 Deferred | Phase 6+ | -| `export` verb | 🟒 Deferred | Phase 6+ | -| Non-`GeneralView` rendering | πŸ”΄ Not started | Phases 8–13 | -| Full OMG graphical notation conformance | 🟑 In progress | Phases 6–13 | -| `feature/auto-flag` branch | 🟑 Open PR | Needs lint + PR | +| Loadable theme files | 🟒 Deferred | Future | +| `export` verb | 🟒 Deferred | Future | +| Non-`GeneralView` rendering | βœ… Complete | 7 of 8 view types implemented | +| Full OMG graphical notation conformance | 🟑 In progress | 7 of 8 views; refinements ongoing | diff --git a/docs/design/definition.yaml b/docs/design/definition.yaml index aa936a7c..ee2b7e00 100644 --- a/docs/design/definition.yaml +++ b/docs/design/definition.yaml @@ -2,6 +2,13 @@ resource-path: - docs/design - docs/design/sysml2-tools-core + - docs/design/sysml2-tools-core/semantic + - docs/design/sysml2-tools-core/semantic/internal + - docs/design/sysml2-tools-core/layout + - docs/design/sysml2-tools-core/layout/engine + - docs/design/sysml2-tools-core/layout/internal + - docs/design/sysml2-tools-core/rendering + - docs/design/sysml2-tools-core/rendering/internal - docs/design/sysml2-tools-tool - docs/design/sysml2-tools-tool/cli - docs/design/sysml2-tools-tool/lint @@ -24,6 +31,34 @@ input-files: - docs/design/sysml2-tools-tool/utilities/path-helpers.md - docs/design/sysml2-tools-core.md - docs/design/sysml2-tools-core/parser.md + - docs/design/sysml2-tools-core/semantic.md + - docs/design/sysml2-tools-core/semantic/workspace-loader.md + - docs/design/sysml2-tools-core/semantic/internal.md + - docs/design/sysml2-tools-core/semantic/internal/sysml-node.md + - docs/design/sysml2-tools-core/semantic/internal/ast-builder.md + - docs/design/sysml2-tools-core/semantic/internal/symbol-table.md + - docs/design/sysml2-tools-core/semantic/internal/reference-resolver.md + - docs/design/sysml2-tools-core/semantic/internal/supertype-walker.md + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/connector-label-placer.md + - docs/design/sysml2-tools-core/layout/engine.md + - docs/design/sysml2-tools-core/layout/engine/channel-router.md + - docs/design/sysml2-tools-core/layout/engine/force-directed-engine.md + - docs/design/sysml2-tools-core/layout/engine/port-assigner.md + - docs/design/sysml2-tools-core/layout/engine/layered-layout-engine.md + - docs/design/sysml2-tools-core/layout/engine/containment-packer.md + - docs/design/sysml2-tools-core/layout/internal.md + - docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md + - docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md + - docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md + - docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md + - docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md + - docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md + - docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md + - docs/design/sysml2-tools-core/layout/internal/layout-warnings.md + - docs/design/sysml2-tools-core/rendering.md + - docs/design/sysml2-tools-core/rendering/internal.md + - docs/design/sysml2-tools-core/rendering/internal/diagram-type-router.md - docs/design/sysml2-tools-svg.md - docs/design/sysml2-tools-png.md - docs/design/ots.md diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 03e03c90..6d877103 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -55,10 +55,29 @@ system, subsystem, and unit levels: - **StdlibGen** (Build-time tool) β€” console tool that parses stdlib source files and writes stdlib.bin - **Program** (Unit) β€” entry point: parses stdlib, runs resolution, serializes to stdlib.bin - **DemaConsulting.SysML2Tools** (System) β€” core library: layout, rendering interfaces, and DiagramRenderer - - **Layout** (Subsystem) β€” LayoutTree intermediate representation: nine node types covering all SysML diagram elements - - **Internal** (Subsystem) β€” internal layout implementation - - **GeneralViewLayoutStrategy** (Unit) β€” two-column grid layout for general view diagrams - - **Rendering** (Subsystem) β€” rendering pipeline interfaces: IRenderer, ILayoutStrategy, Theme, RenderOptions, DiagramRenderer + - **Layout** (Subsystem) β€” LayoutTree intermediate representation (node types covering all SysML + diagram elements), reusable layout engines, and per-view layout strategies + - **Engine** (Subsystem) β€” reusable, model-independent geometric layout engines + - **ChannelRouter** (Unit) β€” orthogonal connector routing with obstacle avoidance and clearance + - **ForceDirectedEngine** (Unit) β€” force-directed node placement from connection springs + - **PortAssigner** (Unit) β€” assigns ports to box sides and distributes them along each edge + - **LayeredLayoutEngine** (Unit) β€” layered (Sugiyama-style) top-to-bottom placement + - **ContainmentPacker** (Unit) β€” packs sized boxes within a bounded container region + - **Internal** (Subsystem) β€” per-view layout strategies + - **GeneralViewLayoutStrategy** (Unit) β€” general view: grouped definitions with specialization edges + - **InterconnectionViewLayoutStrategy** (Unit) β€” internal structure: nested parts, ports, connectors + - **StateTransitionViewLayoutStrategy** (Unit) β€” state machine: states and guarded transitions + - **ActionFlowViewLayoutStrategy** (Unit) β€” layered action flow with start/done markers + - **SequenceViewLayoutStrategy** (Unit) β€” lifelines and ordered messages + - **GridViewLayoutStrategy** (Unit) β€” specialization/relationship matrix + - **BrowserViewLayoutStrategy** (Unit) β€” indented membership tree + - **LayoutWarnings** (Unit) β€” builder for layout diagnostic warning messages + - **ConnectorLabelPlacer** (Unit) β€” collision-aware placement of connector midpoint labels + - **Rendering** (Subsystem) β€” rendering pipeline: the `IRenderer`/`ILayoutStrategy` interfaces, + `Theme`, `RenderOptions`, `RenderOutput`, the `DiagramRenderer` orchestrator, and the + `StdlibFilter` helper that excludes standard-library elements from diagrams + - **Internal** (Subsystem) β€” internal rendering implementation + - **DiagramTypeRouter** (Unit) β€” selects a layout strategy from a view's resolved kind - **DemaConsulting.SysML2Tools.Svg** (System) β€” SVG renderer: renders `LayoutTree` to SVG output with zero external dependencies - **SvgRenderer** (Unit) β€” translates a `LayoutTree` to a self-contained SVG 1.1 document diff --git a/docs/design/sysml2-tools-core/layout.md b/docs/design/sysml2-tools-core/layout.md index 6d587fe0..9dd3d979 100644 --- a/docs/design/sysml2-tools-core/layout.md +++ b/docs/design/sysml2-tools-core/layout.md @@ -180,48 +180,23 @@ decisions recorded here reflect constraints imposed during the Phase 3 vocabular are in the same `DemaConsulting.SysML2Tools.Layout` namespace, so no cross-namespace import is required. -### Internal Subsystem - -#### GeneralViewLayoutStrategy - -##### Purpose - -`GeneralViewLayoutStrategy` implements `ILayoutStrategy` to produce a two-column grid -layout for general view diagrams. It collects all user-defined `part def` elements from -the workspace, groups them by parent package, and arranges the group boxes into two -left-to-right columns. Specialization relationships between `part def` elements with -declared supertypes are represented as `LayoutLine` nodes with open arrowheads. - -##### Data Model - -`GeneralViewLayoutStrategy` has no instance state. All inputs are supplied through -`BuildLayout` parameters. Layout constants (margins, gaps, minimum box sizes) are -declared as `private const double` fields. - -##### Key Methods - -**`BuildLayout(ViewContext context, RenderOptions options)`** - -Entry point. Calls `CollectUserPartDefs` to gather non-stdlib `part def` declarations. -Returns a minimal 200Γ—100 `LayoutTree` when no user part defs are found. Otherwise -calls `GroupByPackage` and `BuildGridLayout` to produce the full layout tree. - -**`CollectUserPartDefs(SysmlWorkspace workspace)`** - -Iterates `workspace.Declarations`, keeping only `SysmlDefinitionNode` entries with -`DefinitionKeyword == "part def"` that pass `StdlibFilter.IsStdlibElement`. - -**`BuildGridLayout(groups, theme)`** - -Places group `LayoutBox` nodes alternately in left and right columns. Computes column -widths from the maximum group width in each column, then assigns absolute `(X, Y)` -coordinates. Calls `AddSpecializationLines` to append `LayoutLine` nodes. - -##### Dependencies - -- `ILayoutStrategy` (in `DemaConsulting.SysML2Tools.Rendering`) β€” interface -- `StdlibFilter` (in `DemaConsulting.SysML2Tools.Rendering.Internal`) β€” stdlib exclusion -- `SysmlDefinitionNode` (in `DemaConsulting.SysML2Tools.Semantic.Internal`) β€” part def nodes +### Subsystem Structure + +Beyond the `LayoutTree` data model described above, the Layout subsystem contains two +sub-subsystems and one helper unit, each documented in its own chapter: + +- **Engine** β€” the reusable, model-independent geometric layout engines (`ChannelRouter`, + `ForceDirectedEngine`, `PortAssigner`, `LayeredLayoutEngine`, `ContainmentPacker`). See + the *Layout Engine Subsystem* chapter. +- **Internal** β€” the per-view layout strategies that map the semantic model to a + `LayoutTree` (general, interconnection, state transition, action flow, sequence, grid, and + browser views), plus `LayoutWarnings`. See the *Layout Internal Subsystem* chapter. +- **ConnectorLabelPlacer** β€” collision-aware placement of connector midpoint labels. See its + own unit chapter. + +The view strategies own the mapping from the SysML semantic model into geometric input, +invoke one or more engines to compute geometry, and assemble the resulting `LayoutTree`. The +engines themselves never reference the semantic model. ### Requirements Traceability diff --git a/docs/design/sysml2-tools-core/layout/connector-label-placer.md b/docs/design/sysml2-tools-core/layout/connector-label-placer.md new file mode 100644 index 00000000..8ce01be3 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/connector-label-placer.md @@ -0,0 +1,53 @@ +### ConnectorLabelPlacer + +#### Purpose + +`ConnectorLabelPlacer` computes non-overlapping screen positions for connector (midpoint) labels. +Its single responsibility is to assign each labelled line a label centre that reads as belonging to +that connector while keeping labels from colliding with one another. Both the SVG and PNG renderers +share this unit so their label layouts match. + +#### Data Model + +`ConnectorLabelPlacer` is a static class with no instance state. Inputs are the lines to place +labels for (in render order) and the body font size. It uses a private `Rect` value type for label +overlap tests. Output is a dictionary mapping each labelled `LayoutLine` to its chosen `(X, Y)` +label centre; lines without a label are absent from the dictionary. + +#### Key Methods + +##### `Place(lines, fontSize)` + +Iterates the lines in order, skipping any with no label or no waypoints. For each labelled line it +estimates the label box half-width and half-height from the text length and font size (plus a small +clearance gap), chooses a position via `ChoosePosition`, records the occupied box, and stores the +result. Processing lines in input order makes the result deterministic. + +##### `ChoosePosition(waypoints, halfWidth, halfHeight, placed)` + +Selects a label centre for one line: + +1. The segment midpoints are ordered by descending segment length, so the longest (most open) run is + preferred. +2. The first segment midpoint that does not overlap an already-placed label box is used. +3. If every segment midpoint collides, the label is nudged along the longest segment's perpendicular + in increasing steps (alternating sides) until a clear position is found. +4. If no clear position is found, the longest segment's midpoint is used as a fallback. + +A single-waypoint line places its label at that point. `Collides` performs the axis-aligned box +overlap test used throughout. + +#### Error Handling + +A null `lines` argument throws `ArgumentNullException`. Degenerate input never throws: lines with no +label or no waypoints are skipped, and a line whose labels cannot be separated falls back to its +longest-segment midpoint rather than failing. + +#### Dependencies + +- `LayoutLine` and `Point2D` (Layout subsystem). + +#### Callers + +The SVG and PNG renderers call `ConnectorLabelPlacer.Place` to position connector labels before +drawing them. diff --git a/docs/design/sysml2-tools-core/layout/engine.md b/docs/design/sysml2-tools-core/layout/engine.md new file mode 100644 index 00000000..c40439f8 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/engine.md @@ -0,0 +1,36 @@ +### Layout Engine Subsystem + +#### Overview + +The Engine subsystem provides the reusable geometric layout engines used by the per-view +layout strategies. Each engine solves one well-defined placement or routing problem from +plain geometric input β€” box sizes, connection pairs, anchor points, and obstacle +rectangles β€” and returns computed geometry. No engine references the SysML semantic model, +so every engine is reusable across view strategies and testable in isolation. + +The subsystem contains the following units: + +| Unit | Responsibility | +| --- | --- | +| `ChannelRouter` | Routes an orthogonal connector between two anchors, avoiding obstacles | +| `ForceDirectedEngine` | Places connected nodes using attraction/repulsion relaxation | +| `LayeredLayoutEngine` | Places a directed graph in top-to-bottom layers | +| `PortAssigner` | Assigns ports to box sides and distributes them along each edge | +| `ContainmentPacker` | Packs sized boxes within a bounded container region | + +#### Interfaces + +Each engine exposes a single static entry point that accepts plain geometric records +(sizes, edges, anchors, obstacle rectangles) and returns computed geometry (placed +rectangles, ordered waypoints, or port placements). The engines consume and produce the +geometric value types declared in the Layout subsystem (for example `Point2D` and the +internal `Rect`); they do not consume `SysmlNode` or any semantic type. + +#### Design + +The view strategies (see *Layout Internal Subsystem*) own the mapping from the semantic +model to geometric input and back. They call the engines to obtain geometry and then build +the `LayoutTree`. This separation keeps the engines small, single-purpose, and independently +verifiable, and lets a strategy combine several engines (for example, force-directed +placement followed by orthogonal routing). The detailed algorithm of each engine is +described in its own unit chapter. diff --git a/docs/design/sysml2-tools-core/layout/engine/channel-router.md b/docs/design/sysml2-tools-core/layout/engine/channel-router.md new file mode 100644 index 00000000..8803cac0 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/engine/channel-router.md @@ -0,0 +1,67 @@ +#### ChannelRouter + +##### Purpose + +`ChannelRouter` 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 routed connector quality (state transitions, action successions, +interconnection connectors, specialization edges) flows. + +##### Data Model + +`ChannelRouter` is a static class with no instance state. Inputs are the source and target +`Point2D` anchors, a list of obstacle `Rect`, a clearance distance, and optional source and +target `PortSide` values. The result is a `RouteResult` record carrying the ordered +`Waypoints` and a `Crossed` flag. + +##### Key Methods + +###### `RouteWithStatus(source, target, obstacles, clearance, sourceSide?, targetSide?)` + +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 along the step axis, so two stubs facing + each other across a narrow gap meet at the midline instead of overshooting (which would + produce a visible reversal at the arrowhead). +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 are rejected when they pass within + the current clearance of an obstacle (the obstacles are inflated by the clearance and + tested with strict inequalities, so a segment exactly one clearance away is allowed). The + largest clearance that yields an obstacle-free path is used. At clearance zero, grid lines + sit on box edges and edge-hugging routes are permitted, so a clean path is found in almost + all cases. +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, but U-turns are preserved so a + perpendicular stub is never collapsed, and duplicate points are dropped. + +The turn penalty in the search biases toward routes with fewer bends, so connectors prefer +straight runs where the geometry allows. + +###### `Route(...)` + +A thin wrapper that returns only the `Waypoints` of `RouteWithStatus`, for callers that do +not need the crossing status. + +##### 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. + +##### Dependencies + +- `Point2D` and the internal `Rect` geometric value types (Layout subsystem). +- `PortSide` (Layout subsystem) for perpendicular-stub direction. + +##### Callers + +Every view strategy that draws connectors: `GeneralViewLayoutStrategy`, +`InterconnectionViewLayoutStrategy`, `StateTransitionViewLayoutStrategy`, and +`ActionFlowViewLayoutStrategy`. The `Crossed` flag feeds `LayoutWarnings`. diff --git a/docs/design/sysml2-tools-core/layout/engine/containment-packer.md b/docs/design/sysml2-tools-core/layout/engine/containment-packer.md new file mode 100644 index 00000000..c525653c --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/engine/containment-packer.md @@ -0,0 +1,57 @@ +#### 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 (for example, the parts inside a block) in a compact, +ordered grid. + +##### 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)`. + +##### Key Methods + +###### `Pack(items, maxContentWidth, horizontalGap, verticalGap, padding)` + +Computes the packing. The algorithm is 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's height + tracks the tallest item placed so far. +3. **Wrapping.** Before placing an item that is not the first in its row, the packer checks whether + its right edge would exceed `padding + maxContentWidth`. If so, it drops to a new row below the + current one (advancing the row top by the current row height plus `verticalGap`), resets the + cursor to the left padding offset, 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 rather + than being dropped, 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. Tracking the widest content right edge across all rows is + what lets an oversized item extend the region width. + +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. + +##### 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. + +##### Dependencies + +- `PackedRect` (Layout subsystem) β€” the placed-rectangle value type returned in the result. This + type is declared alongside `ContainmentPacker`. + +##### Callers + +View layout strategies that pack child elements inside a containing box, using the returned +rectangles to position children and the region size to size the container. diff --git a/docs/design/sysml2-tools-core/layout/engine/force-directed-engine.md b/docs/design/sysml2-tools-core/layout/engine/force-directed-engine.md new file mode 100644 index 00000000..a9f9cd15 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/engine/force-directed-engine.md @@ -0,0 +1,59 @@ +#### ForceDirectedEngine + +##### Purpose + +`ForceDirectedEngine` arranges a set of nodes connected by undirected edges into a spread-out, +non-overlapping placement. It produces the organic, balanced arrangement used for general block +and interconnection views where there is no inherent top-to-bottom flow. The result is a region +size and one rectangle per input node, translated so the region origin is `(0, 0)` plus a uniform +padding margin. + +##### Data Model + +`ForceDirectedEngine` is a static class with no instance state. Inputs are a list of `ForceNode` +records (each carrying a `Width` and `Height`), a list of `ForceEdge` records (each an undirected +pair of node indices), a `spacing` distance, and a `padding` margin. The result is a `ForceResult` +record carrying the region `Width`, `Height`, and the ordered list of placed `PackedRect` +rectangles, one per input node in input order. + +##### Key Methods + +###### `Place(nodes, edges, spacing, padding)` + +Computes the placement. The algorithm is: + +1. **Degenerate cases.** An empty node list returns a region of `2 * padding` on each axis with no + rectangles. A single node is placed at `(padding, padding)` and the region is sized to that node + plus padding on each side. +2. **Deterministic seed.** Initial node centres are laid out on a golden-angle spiral (radius + growing with the square root of the index) centred on the origin. Seeding from a fixed spiral + rather than a random number generator is what makes the result reproducible across runs and + platforms. +3. **Force simulation.** A Fruchterman-Reingold simulation runs for a fixed number of cooling + iterations. Every pair of nodes contributes a repulsive force inversely proportional to the + distance between them, and every edge contributes an attractive spring force proportional to the + square of the distance, both scaled by an optimal-distance constant `k` derived from `spacing`. + Each iteration displaces nodes by the summed force, capped by a temperature that cools linearly + to zero, so early iterations make large moves and later iterations fine-tune. +4. **Overlap removal.** A final pass repeatedly separates any two node bounding boxes that still + overlap (with a small margin derived from `spacing`), pushing each pair apart along its axis of + least penetration until no overlaps remain or an iteration cap is reached. This pass is what + guarantees the non-overlap postcondition regardless of how the force simulation settled. +5. **Finalize.** Node centres are converted to top-left rectangles, the whole arrangement is + translated so its minimum corner sits at the padding offset, and the region width and height are + computed from the arrangement extent plus padding on each side. + +##### Error Handling + +Null `nodes` or `edges` arguments throw `ArgumentNullException`. All other inputs are handled +without throwing: empty and single-node inputs return well-formed degenerate results, and the +overlap-removal pass is bounded by an iteration cap so it always terminates. + +##### Dependencies + +- `PackedRect` (Layout subsystem) β€” the placed-rectangle value type returned in the result. + +##### Callers + +View layout strategies that arrange loosely structured graphs without an inherent directional +flow, where an organic balanced placement reads better than a layered or packed one. diff --git a/docs/design/sysml2-tools-core/layout/engine/layered-layout-engine.md b/docs/design/sysml2-tools-core/layout/engine/layered-layout-engine.md new file mode 100644 index 00000000..61617276 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/engine/layered-layout-engine.md @@ -0,0 +1,60 @@ +#### LayeredLayoutEngine + +##### Purpose + +`LayeredLayoutEngine` arranges a directed graph into a top-to-bottom layered flow. It assigns each +node to a horizontal layer, orders the nodes within each layer to reduce edge crossings, and gives +each node absolute coordinates so the diagram reads as a directed flow from the top down. The +result is a region size, one rectangle per input node, and the layer index of each node. + +##### Data Model + +`LayeredLayoutEngine` is a static class with no instance state. Inputs are a list of `LayeredNode` +records (each a `Width` and `Height`), a list of `LayeredEdge` records (each a directed `From`/`To` +index pair), a `layerGap`, a `nodeGap`, and a `padding`. The result is a `LayeredResult` record +carrying the region `Width`, `Height`, the ordered list of placed `PackedRect` rectangles (one per +node in input order), and the `Layers` list giving each node's assigned layer index. + +##### Key Methods + +###### `Place(nodes, edges, layerGap, nodeGap, padding)` + +Computes the placement. The algorithm is a simplified Sugiyama pipeline: + +1. **Degenerate case.** An empty node list returns a region of `2 * padding` on each axis with no + rectangles and no layers. +2. **Cycle breaking.** A depth-first traversal classifies any edge that points back to a node still + on the recursion stack as a back edge and reverses it. Self-loops and duplicate edges are + dropped. This produces an acyclic edge set so that layering terminates even when the input + contains feedback loops. +3. **Layer assignment.** Over the acyclic edge set, each node is assigned the layer equal to its + longest path from any source, computed by a topological sweep. This guarantees every non-reversed + edge runs from a strictly smaller layer to a larger one. +4. **Crossing reduction.** Nodes within each layer are reordered by repeated barycenter sweeps + (alternating downward and upward) that place each node near the average position of its + neighbors in the adjacent layer, reducing edge crossings while keeping the order stable for + nodes with no neighbors. +5. **Coordinate assignment.** Layers are stacked vertically using `layerGap` and the tallest node + in each layer. Within a layer, nodes are given x-coordinates by an alignment relaxation that + pulls each node toward the average centre of its neighbors while enforcing the minimum + `nodeGap`, by averaging an order-preserving left-to-right and right-to-left placement (both of + which respect the gap, so their average does too). This straightens the flow into a spine + without letting same-layer nodes overlap. The arrangement is finally translated so the left-most + node edge sits at `padding`, and the region width and height are computed from the extent plus + padding. + +##### Error Handling + +Null `nodes` or `edges` arguments throw `ArgumentNullException`. All other inputs are handled +without throwing: cycles are broken so layering always terminates, and empty input returns a +well-formed empty result. + +##### Dependencies + +- `PackedRect` (Layout subsystem) β€” the placed-rectangle value type returned in the result. + +##### Callers + +View layout strategies that render directed graphs with an inherent flow direction, such as +activity and state diagrams, where a layered top-to-bottom arrangement is the expected reading +order. diff --git a/docs/design/sysml2-tools-core/layout/engine/port-assigner.md b/docs/design/sysml2-tools-core/layout/engine/port-assigner.md new file mode 100644 index 00000000..a5d6c0f8 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/engine/port-assigner.md @@ -0,0 +1,51 @@ +#### PortAssigner + +##### Purpose + +`PortAssigner` decides where the connection ports of a single box sit on that box's outline. For +each port it selects the box side facing the port's connection target and computes the absolute +centre point on that side. When several ports land on the same side it spreads them across evenly +spaced slots. This gives connectors clean, well-separated attachment points on each box. + +##### Data Model + +`PortAssigner` is a static class with no instance state. Input is a list of `PortRequest` records, +each carrying the owning box `Rect` and a `Toward` `Point2D` (the point the port's connection heads +toward, typically the centre of the connected box). The result is a list of `PortPlacement` +records, one per request in input order, each carrying the absolute `CentreX`, `CentreY`, and the +chosen `PortSide`. + +##### Key Methods + +###### `Assign(requests)` + +Computes the placements. The algorithm is: + +1. **Side selection.** For each request the side is chosen from the direction of `Toward` relative + to the box centre: the axis (horizontal or vertical) of greater magnitude decides whether the + port goes on a left/right or top/bottom side, and the sign decides which of the two. This places + each port on the side whose outward normal best points at its target. +2. **Grouping.** Port indices are grouped by their chosen side. +3. **Slot distribution.** Within each side, the ports are ordered by their target's coordinate + along that edge (X for top/bottom, Y for left/right) so that connectors cross as little as + possible, then placed at evenly spaced fractional slots β€” slot `s` of `count` ports sits at + fraction `(s + 1) / (count + 1)` along the edge. This guarantees distinct, evenly spaced + positions for any number of ports sharing a side. +4. **Coordinate computation.** Each slot fraction is mapped to an absolute point on the chosen side + of the box rectangle. + +##### Error Handling + +A null `requests` argument throws `ArgumentNullException`. An empty request list returns an empty +result. No other input causes a throw; any direction yields a valid side, and the slot computation +is well-defined for any positive port count. + +##### Dependencies + +- `Rect` and `Point2D` (Layout subsystem) β€” the geometric input value types. +- `PortSide` (Layout subsystem) β€” the side enumeration returned in each placement. + +##### Callers + +View layout strategies that draw boxes with ports, which use the returned side and centre to anchor +each connector to its box. diff --git a/docs/design/sysml2-tools-core/layout/internal.md b/docs/design/sysml2-tools-core/layout/internal.md new file mode 100644 index 00000000..363d94c4 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal.md @@ -0,0 +1,41 @@ +### Layout Internal Subsystem + +#### Overview + +The Internal subsystem provides the per-view layout strategies β€” the implementations of +`ILayoutStrategy` that turn the SysML semantic model into a `LayoutTree` for one diagram view. +Where the Engine subsystem solves geometric sub-problems from plain input, the Internal +strategies own the mapping from the semantic model to geometric input and back: they select +the relevant model elements, size and place the boxes, route the connectors, and assemble the +node tree the renderers consume. + +The subsystem contains one strategy per supported view type: + +| Unit | Responsibility | +| --- | --- | +| `GeneralViewLayoutStrategy` | Lays out user definitions grouped by package with specialization edges | +| `InterconnectionViewLayoutStrategy` | Lays out the internal parts, ports, and connections of one part definition | +| `StateTransitionViewLayoutStrategy` | Lays out states, an initial marker, and guarded transitions | +| `ActionFlowViewLayoutStrategy` | Lays out actions top-to-bottom with start/done markers and successions | + +The subsystem also contains the `BrowserViewLayoutStrategy`, `GridViewLayoutStrategy`, and +`SequenceViewLayoutStrategy` strategies and the `LayoutWarnings` helper, each documented in its +own chapter. + +#### Interfaces + +Each strategy exposes the single `ILayoutStrategy.BuildLayout(ViewContext, RenderOptions)` +method. It consumes the semantic workspace through `ViewContext` and the theme and render +options through `RenderOptions`, and returns a `LayoutTree`. The strategies are the only +consumers of both the semantic model and the geometric engines; the renderers see only the +returned tree. + +#### Design + +Each strategy follows the same shape: collect the relevant model elements (excluding +standard-library declarations), compute an intrinsic size for each box, delegate placement and +routing to the geometric engines of the Engine subsystem, and build the `LayoutNode` tree. When +a connector cannot be routed without crossing a box, the strategy records a layout warning +through `LayoutWarnings` rather than silently producing a misleading diagram. A view with no +relevant elements returns a minimal empty canvas. The detailed mapping and heuristics of each +strategy are described in its own unit chapter. diff --git a/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md new file mode 100644 index 00000000..a035de78 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md @@ -0,0 +1,67 @@ +#### ActionFlowViewLayoutStrategy + +##### Purpose + +`ActionFlowViewLayoutStrategy` implements `ILayoutStrategy` to produce an Action Flow View +diagram. It renders action usages as rounded boxes arranged top-to-bottom in layers, with a start +marker entering the actions that have no predecessor, a done marker leaving the actions that have +no successor, and successions drawn as downward flow arrows. + +##### Data Model + +`ActionFlowViewLayoutStrategy` has no instance state; all input arrives through the `BuildLayout` +parameters. Layout constants (`MinActionWidth`, `CharWidthFactor`, `MarkerSize`, `MarkerBand`, +`FlowClearance`) are declared as `private const double` fields. A private `ActionItem` record +carries each action with its computed box size; successions are carried as `(int From, int To)` +index pairs. + +##### Key Methods + +###### `BuildLayout(ViewContext context, RenderOptions options)` + +Entry point. Selects the root definition via `FindRoot`, collects its actions, resolves its +successions, lays the actions out in layers, adds the succession edges and the start/done markers, +and assembles the tree. Returns a minimal 200Γ—100 empty `LayoutTree` when no root or no actions +are found. + +###### `FindRoot(workspace)` and `CollectActions(root, theme)` + +`FindRoot` chooses the non-standard-library definition that scores highest on successions (then +actions). `CollectActions` gathers the declared `action` usages and any action named only by a +succession endpoint, building a name β†’ index lookup. + +###### `ResolveSuccessions(root, index)` + +Maps each succession's source and target β€” by their last `::`-separated name segment β€” to action +indices, keeping only distinct, resolvable pairs. + +###### Placement and routing + +Action boxes are positioned by `LayeredLayoutEngine.Place`, which assigns top-to-bottom layers so +a target sits below its source, then shifted down to leave a marker band above the first layer. +`AddSuccessionEdges` routes each succession with `ChannelRouter.RouteWithStatus` from the bottom +of the source to the top of the target, keeping `FlowClearance` from unrelated boxes, and returns +the number of edges that had to cross a box. `AddStartAndDone` places a filled-circle start marker +centred over the actions with no incoming edge and a bullseye done marker centred under the actions +with no outgoing edge, joining each with a straight flow line. + +##### Error Handling + +Null `context` or `options` arguments throw `ArgumentNullException`. The absence of an eligible +action definition or of actions is not an error: the method returns the minimal empty canvas. +Successions that cannot be routed cleanly are still drawn and counted as crossings, which are +surfaced through `LayoutWarnings`. + +##### Dependencies + +- `ILayoutStrategy`, `ViewContext`, `RenderOptions`, `Theme` (Rendering subsystem) β€” the strategy contract and inputs. +- `LayeredLayoutEngine`, `ChannelRouter`, `BoxMetrics` (Layout Engine subsystem) β€” layered placement and routing. +- `StdlibFilter` (Rendering Internal subsystem) β€” standard-library exclusion. +- `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode`, `SysmlTransitionNode` (Semantic subsystem) β€” model input. +- `LayoutWarnings` (Layout Internal subsystem) β€” crossing-warning construction. +- The `LayoutTree`, `LayoutBox`, `LayoutBadge`, and `LayoutLine` data types (Layout subsystem). + +##### Callers + +The Rendering subsystem selects `ActionFlowViewLayoutStrategy` when rendering an Action Flow View. +No other unit calls it directly. diff --git a/docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md new file mode 100644 index 00000000..488d45a8 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md @@ -0,0 +1,54 @@ +#### BrowserViewLayoutStrategy + +##### Purpose + +`BrowserViewLayoutStrategy` lays out a Browser View: it presents the membership hierarchy of the +workspace's user-defined elements as an indented tree of rows, with connector lines from each parent +to its children. Its single responsibility is to turn the qualified-name hierarchy into a positioned +`LayoutTree`. + +##### Data Model + +The strategy is a stateless `ILayoutStrategy`. Inputs are a `ViewContext` (carrying the +`SysmlWorkspace`) and `RenderOptions` (carrying the `Theme`). It uses a private `TreeNode` record +holding a node's qualified name, display label, optional keyword, and child nodes. Output is a +`LayoutTree` whose nodes are `LayoutBox` rows and `LayoutLine` parent-to-child connectors. + +##### Key Methods + +###### `BuildLayout(context, options)` + +Walks the membership forest and emits rows: + +1. **Forest construction.** `BuildForest` takes the non-stdlib declarations in deterministic + (ordinal qualified-name) order so parents precede children, and links each element to the parent + identified by the prefix before its last `::` separator; elements with no known parent become + roots. +2. **Recursive emission.** `EmitNode` lays out each row left-to-right at an X derived from its depth + times a fixed indentation, advancing a shared Y cursor downward. Each row becomes a `LayoutBox` + whose label combines the element keyword and simple name and whose width fits the label. +3. **Connectors.** For every non-root row a `LayoutLine` is emitted from a vertical stem dropped from + the parent row down to the child's vertical centre and across to the child box, so the connector + never crosses the parent's own box or text. +4. **Canvas sizing.** The overall width follows the right-most box and the height follows the final + Y cursor. + +When there are no user-defined elements, a minimal empty `LayoutTree` with no nodes is returned. + +##### Error Handling + +Null `context` or `options` arguments throw `ArgumentNullException`. An empty workspace does not +throw: the strategy returns an empty diagram rather than failing. + +##### Dependencies + +- `LayoutTree`, `LayoutBox`, `LayoutLine`, `Point2D`, `BoxShape`, `ArrowheadStyle`, `LineStyle` + (Layout subsystem). +- `ViewContext`, `RenderOptions`, `Theme` (Rendering subsystem). +- `SysmlWorkspace`, `SysmlNode`, `SysmlPackageNode`, `SysmlDefinitionNode`, `SysmlFeatureNode`, + `SysmlViewNode`, and `StdlibFilter` (Semantic subsystem). + +##### Callers + +The layout strategy registry selects `BrowserViewLayoutStrategy` when a Browser View is requested; +it is not called directly by other units. diff --git a/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md new file mode 100644 index 00000000..b850095b --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md @@ -0,0 +1,74 @@ +#### GeneralViewLayoutStrategy + +##### Purpose + +`GeneralViewLayoutStrategy` implements `ILayoutStrategy` to produce a General View diagram. It +renders every user-defined definition (part, port, interface, requirement, action, and so on) as +a keyword-labelled box, groups the boxes that belong to a package inside a folder-shaped +container, lists each definition's owned usages in compartments, and draws specialization edges +between subtypes and their supertypes. + +##### Data Model + +`GeneralViewLayoutStrategy` has no instance state; all input arrives through the `BuildLayout` +parameters. Layout constants (`MinBoxWidth`, `CharWidthFactor`, `EdgeClearance`) are declared as +`private const double` fields. Two private records carry intermediate data: `DefBox` (a +user definition with its computed size, keyword, supertype names, and compartments) and +`PlacedBox` (a definition with absolute coordinates, used as an edge anchor). + +##### Key Methods + +###### `BuildLayout(ViewContext context, RenderOptions options)` + +Entry point. Calls `CollectDefinitions` to gather user definitions; returns a minimal +200Γ—100 empty `LayoutTree` when none are found. Otherwise groups the definitions by package, +places the groups, routes the specialization edges, and returns the assembled tree with any +crossing warnings attached. + +###### `CollectDefinitions(workspace, theme)` + +Iterates `workspace.Declarations`, keeping each `SysmlDefinitionNode` that is not a +standard-library element (per `StdlibFilter.IsStdlibElement`). For each kept definition it builds +the compartments from the owned usage features (grouped by keyword, each formatted as a +`name : Type [n]` row) and computes the box size from the title and the longest compartment row. + +###### `GroupByPackage(defs)` + +Groups definitions by the qualified-name prefix before the last `::`, preserving first-seen +order. Top-level definitions (no package prefix) form their own standalone blocks. + +###### `PlaceGroups(groups, theme, depthLimit)` + +Packs the definition boxes of each package inside a folder box using `ContainmentPacker`, then +packs the folder boxes and standalone boxes across the canvas with a second `ContainmentPacker` +pass. A full title area is reserved above each folder's contents so the package label never +overlaps the first child. When the depth limit forbids the nested level, a folder's contents are +replaced with a single ellipsis indicator. + +###### `BuildSpecializationEdges(defs, placed)` + +For each definition with a declared supertype present in the workspace, routes an orthogonal line +with `ChannelRouter` from the subtype box to the supertype box, keeping `EdgeClearance` from +unrelated boxes, and emits a `LayoutLine` with an open arrowhead at the supertype end. Returns +the routed lines and the count of edges that had to cross a box. + +##### Error Handling + +Null `context` or `options` arguments throw `ArgumentNullException`. A workspace with no user +definitions is not an error: the method returns the minimal empty canvas. Edges that cannot be +routed cleanly are still drawn and counted as crossings, which are surfaced through +`LayoutWarnings` rather than failing the layout. + +##### Dependencies + +- `ILayoutStrategy`, `ViewContext`, `RenderOptions`, `Theme` (Rendering subsystem) β€” the strategy contract and inputs. +- `ContainmentPacker` and `ChannelRouter` (Layout Engine subsystem) β€” box packing and edge routing. +- `StdlibFilter` (Rendering Internal subsystem) β€” standard-library exclusion. +- `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode` (Semantic subsystem) β€” model input. +- `LayoutWarnings` (Layout Internal subsystem) β€” crossing-warning construction. +- The `LayoutTree`, `LayoutBox`, `LayoutCompartment`, and `LayoutLine` data types (Layout subsystem). + +##### Callers + +The Rendering subsystem selects `GeneralViewLayoutStrategy` when rendering a General View. No +other unit calls it directly. diff --git a/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md new file mode 100644 index 00000000..ce44377f --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md @@ -0,0 +1,52 @@ +#### GridViewLayoutStrategy + +##### Purpose + +`GridViewLayoutStrategy` lays out a Grid View as a specialization relationship matrix: the +workspace's user-defined definitions form both the rows and the columns, and a cell is marked where +the row definition specializes the column definition. Its single responsibility is to turn the +definitions and their supertype references into a positioned `LayoutTree`. + +##### Data Model + +The strategy is a stateless `ILayoutStrategy`. Inputs are a `ViewContext` (carrying the +`SysmlWorkspace`) and `RenderOptions` (carrying the `Theme`). It uses a private `DefRow` record +holding a definition's name and its supertype references. Output is a `LayoutTree` containing a +single `LayoutGrid` of `LayoutGridRow` and `LayoutGridCell` values. + +##### Key Methods + +###### `BuildLayout(context, options)` + +Builds the matrix: + +1. **Definition collection.** `CollectDefinitions` gathers the non-stdlib definitions in + deterministic (ordinal qualified-name) order. An index map from simple name to column is built + from them. +2. **Sizing.** Row height derives from the body font size and label padding; the header column width + and the data column width derive from `MaxLabelWidth`, the widest definition label. +3. **Header row.** An empty corner cell is followed by one centered header cell per definition. +4. **Data rows.** For each row definition, a left-aligned header cell carries its name, then one + cell per column carries the mark where `ResolveSupertypeIndices` reports that the row definition + specializes the column definition (matching supertype references to columns by simple name) and + an empty cell otherwise. +5. **Assembly.** The rows are wrapped in a `LayoutGrid` positioned with a small padding offset, and + the overall canvas width and height are computed from the column counts and sizes. + +When there are no user-defined definitions, a minimal empty `LayoutTree` with no nodes is returned. + +##### Error Handling + +Null `context` or `options` arguments throw `ArgumentNullException`. An empty workspace does not +throw: the strategy returns an empty diagram rather than failing. + +##### Dependencies + +- `LayoutTree`, `LayoutGrid`, `LayoutGridRow`, `LayoutGridCell`, `TextAlign` (Layout subsystem). +- `ViewContext`, `RenderOptions`, `Theme` (Rendering subsystem). +- `SysmlWorkspace`, `SysmlDefinitionNode`, and `StdlibFilter` (Semantic subsystem). + +##### Callers + +The layout strategy registry selects `GridViewLayoutStrategy` when a Grid View is requested; it is +not called directly by other units. diff --git a/docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md new file mode 100644 index 00000000..ee5439ad --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md @@ -0,0 +1,70 @@ +#### InterconnectionViewLayoutStrategy + +##### Purpose + +`InterconnectionViewLayoutStrategy` implements `ILayoutStrategy` to produce an Interconnection +View diagram. It shows the internal structure of a single part definition: its nested part usages +as boxes placed by the force-directed engine, ports on the box boundaries for the incident +connections, and the connection usages routed as orthogonal connector lines between the ports, +all enclosed by a container box for the host definition. + +##### Data Model + +`InterconnectionViewLayoutStrategy` has no instance state; all input arrives through the +`BuildLayout` parameters. Layout constants (`MinPartWidth`, `CharWidthFactor`, `PartSpacing`, +`ConnectorClearance`) are declared as `private const double` fields. Two private records carry +intermediate data: `PartItem` (a nested part usage with its computed box size and typing) and +`ConnPair` (a resolved binary connection between two nested-part indices). + +##### Key Methods + +###### `BuildLayout(ViewContext context, RenderOptions options)` + +Entry point. Selects the root part definition via `FindRoot`, collects its parts, resolves its +connections, places the parts, draws ports and connectors, and assembles the container box and +tree. Returns a minimal 200Γ—100 empty `LayoutTree` when no root or no parts are found. + +###### `FindRoot(workspace)` + +Chooses the non-standard-library `part def` with the most connection usages (breaking ties by the +most part usages) as the definition whose interior to render. + +###### `CollectParts(root, theme)` and `ResolveConnections(root, partIndex)` + +`CollectParts` gathers the root's nested `part` usages, sizing each box from its `name : Type` +label. `ResolveConnections` maps each binary connection's dotted endpoint references to nested-part +indices by matching the first segment against the part names, keeping only distinct, resolvable +pairs. + +###### Placement and routing + +The part boxes are positioned by `ForceDirectedEngine.Place`, using the connections as springs at +`PartSpacing`, and then offset below the container's title area. `AddPortsAndConnectors` assigns a +port per incident connection with `PortAssigner`, then routes each connection with +`ChannelRouter.RouteWithStatus`, keeping `ConnectorClearance` from unrelated part boxes. An +alignment pass snaps the two ports of a connection to a shared axis coordinate β€” but only when +each port is the sole occupant of its (facing) edge and the boxes overlap along the connector +axis β€” so a straight connector is drawn without ever moving a box (and therefore never introducing +an overlap). The method returns the number of connectors that had to cross a box. + +##### Error Handling + +Null `context` or `options` arguments throw `ArgumentNullException`. The absence of an eligible +part definition or of nested parts is not an error: the method returns the minimal empty canvas. +Connectors that cannot be routed cleanly are still drawn and counted as crossings, which are +surfaced through `LayoutWarnings`. + +##### Dependencies + +- `ILayoutStrategy`, `ViewContext`, `RenderOptions`, `Theme` (Rendering subsystem) β€” the strategy contract and inputs. +- `ForceDirectedEngine`, `PortAssigner`, `ChannelRouter`, `BoxMetrics` + (Layout Engine subsystem) β€” placement, ports, and routing. +- `StdlibFilter` (Rendering Internal subsystem) β€” standard-library exclusion. +- `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode`, `SysmlConnectionNode` (Semantic subsystem) β€” model input. +- `LayoutWarnings` (Layout Internal subsystem) β€” crossing-warning construction. +- The `LayoutTree`, `LayoutBox`, `LayoutPort`, and `LayoutLine` data types (Layout subsystem). + +##### Callers + +The Rendering subsystem selects `InterconnectionViewLayoutStrategy` when rendering an +Interconnection View. No other unit calls it directly. diff --git a/docs/design/sysml2-tools-core/layout/internal/layout-warnings.md b/docs/design/sysml2-tools-core/layout/internal/layout-warnings.md new file mode 100644 index 00000000..ebbe5751 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/layout-warnings.md @@ -0,0 +1,38 @@ +#### LayoutWarnings + +##### Purpose + +`LayoutWarnings` builds the non-fatal layout-quality warning messages surfaced on a `LayoutTree`. +Its single responsibility is to turn a count of connectors that had to cross a box into the +human-readable warning text for a view. + +##### Data Model + +`LayoutWarnings` is a static class with no instance state. Inputs are the view name and the number +of crossing connectors. Output is a read-only list of warning strings. + +##### Key Methods + +###### `ForCrossings(viewName, crossings)` + +Returns the warnings for a view: + +1. When `crossings` is zero or negative, an empty list is returned. +2. Otherwise a single warning string is produced naming the view and reporting the count. The noun + is rendered in singular form for a count of one and plural form otherwise, and the count is + formatted with the invariant culture. + +##### Error Handling + +N/A - the method performs no validation and does not throw; a non-positive count simply yields an +empty list and any string view name is accepted. + +##### Dependencies + +- `System.Globalization.CultureInfo` for invariant-culture number formatting (.NET base class + library). + +##### Callers + +View layout strategies that route connectors call `LayoutWarnings.ForCrossings` to attach +crossing warnings to the `LayoutTree` they produce. diff --git a/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md new file mode 100644 index 00000000..7b190f4b --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md @@ -0,0 +1,58 @@ +#### SequenceViewLayoutStrategy + +##### Purpose + +`SequenceViewLayoutStrategy` lays out a Sequence View: it renders the interaction described by a +definition's messages as a set of vertical lifelines with header boxes and a horizontal arrow for +each message, ordered top-to-bottom by declaration order. Its single responsibility is to turn the +selected definition's messages into a positioned `LayoutTree`. + +##### Data Model + +The strategy is a stateless `ILayoutStrategy`. Inputs are a `ViewContext` (carrying the +`SysmlWorkspace`) and `RenderOptions` (carrying the `Theme`). It uses a private `MessageItem` +record holding the sender and receiver lifeline indices and the message label. Output is a +`LayoutTree` whose nodes are `LayoutLifeline` headers/stems and `LayoutLine` message arrows. + +##### Key Methods + +###### `BuildLayout(context, options)` + +Builds the diagram: + +1. **Root selection.** `FindRoot` scans the non-stdlib declarations and chooses the definition that + declares the most `message` connections, so the most message-rich definition drives the view. +2. **Lifeline collection.** `CollectLifelines` walks the root's messages and records the distinct + participants in first-appearance order, where a participant is the first dot-separated segment of + a message endpoint reference (for example `client` from `client.a`). An index map from name to + column is built alongside. +3. **Message resolution.** `ResolveMessages` maps each message's endpoints to lifeline indices, + preserving declaration order and skipping messages whose endpoints do not resolve. +4. **Arithmetic placement.** Lifeline X is `margin + headerWidth/2 + columnIndex * pitch`, where + `pitch` is computed by `ComputePitch` from the widest label (clamped to a minimum). Message Y is + `firstMessageY + messageOrdinal * rowPitch`. Header height and margins derive from the theme. +5. **Node emission.** Each lifeline becomes a `LayoutLifeline`; each message becomes a horizontal + `LayoutLine` with no source arrowhead and a filled target arrowhead, carrying the message label + as its midpoint label. A message whose sender and receiver are the same lifeline is emitted by + `BuildSelfMessage` as a small rectangular self-loop. + +When no root is found, or there are no lifelines or messages, a minimal empty `LayoutTree` with no +nodes is returned. + +##### Error Handling + +Null `context` or `options` arguments throw `ArgumentNullException`. Empty or unresolved input does +not throw: the strategy returns an empty diagram rather than failing. + +##### Dependencies + +- `LayoutTree`, `LayoutLifeline`, `LayoutLine`, `Point2D`, `ArrowheadStyle`, `LineStyle` + (Layout subsystem). +- `ViewContext`, `RenderOptions`, `Theme` (Rendering subsystem). +- `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlConnectionNode`, and `StdlibFilter` + (Semantic subsystem). + +##### Callers + +The layout strategy registry selects `SequenceViewLayoutStrategy` when a Sequence View is +requested; it is not called directly by other units. diff --git a/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md new file mode 100644 index 00000000..7c5d4aef --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md @@ -0,0 +1,71 @@ +#### StateTransitionViewLayoutStrategy + +##### Purpose + +`StateTransitionViewLayoutStrategy` implements `ILayoutStrategy` to produce a State Transition +View diagram. It renders state usages as rounded boxes placed by the force-directed engine, an +initial pseudo-state marker entering the first declared state, and transitions as orthogonal +arrows annotated with their guard conditions. + +##### Data Model + +`StateTransitionViewLayoutStrategy` has no instance state; all input arrives through the +`BuildLayout` parameters. Layout constants (`MinStateWidth`, `CharWidthFactor`, `StateSpacing`, +`TransitionClearance`, `InitialMarkerSize`) are declared as `private const double` fields. Two +private records carry intermediate data: `StateItem` (a state with its computed box size) and +`TransitionItem` (a resolved transition between two state indices with an optional guard). + +##### Key Methods + +###### `BuildLayout(ViewContext context, RenderOptions options)` + +Entry point. Selects the root state definition via `FindRoot`, collects its states, resolves its +transitions, places the state boxes, adds the initial marker and the transition edges, and +assembles the tree. Returns a minimal 200Γ—100 empty `LayoutTree` when no root or no states are +found. + +###### `FindRoot(workspace)` and `CollectStates(root, theme)` + +`FindRoot` chooses the non-standard-library definition with the most transitions. `CollectStates` +gathers the declared `state` usages first (preserving declaration order so the first declared +state becomes the initial state), then adds any additional state named only by a transition +endpoint, building a name β†’ index lookup. + +###### `ResolveTransitions(root, index)` + +Maps each transition's source and target β€” by their last `::`-separated name segment β€” to state +indices, carrying the optional guard. + +###### Placement and routing + +State boxes are positioned by `ForceDirectedEngine.Place`, using the non-self transitions as +springs at `StateSpacing`. `AddInitialMarker` places a filled-circle badge above the first state +with a straight arrow into it. `AddTransitions` routes each transition with +`ChannelRouter.RouteWithStatus`, keeping `TransitionClearance` from unrelated boxes, labelling +each line with its bracketed guard. Each transition end attaches to the box side facing the other +state; when several transitions share a side, their endpoints are distributed along that side and +ordered by counterpart position to reduce crossings, and runs of consecutive same-direction +endpoints are collapsed into shared anchor slots so that incoming and outgoing transitions on one +edge never coincide. A self-transition is drawn as a small loop above its state. The method +returns the number of transitions that had to cross a box. + +##### Error Handling + +Null `context` or `options` arguments throw `ArgumentNullException`. The absence of an eligible +state definition or of states is not an error: the method returns the minimal empty canvas. +Transitions that cannot be routed cleanly are still drawn and counted as crossings, which are +surfaced through `LayoutWarnings`. + +##### Dependencies + +- `ILayoutStrategy`, `ViewContext`, `RenderOptions`, `Theme` (Rendering subsystem) β€” the strategy contract and inputs. +- `ForceDirectedEngine`, `ChannelRouter`, `BoxMetrics` (Layout Engine subsystem) β€” placement and routing. +- `StdlibFilter` (Rendering Internal subsystem) β€” standard-library exclusion. +- `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode`, `SysmlTransitionNode` (Semantic subsystem) β€” model input. +- `LayoutWarnings` (Layout Internal subsystem) β€” crossing-warning construction. +- The `LayoutTree`, `LayoutBox`, `LayoutBadge`, and `LayoutLine` data types (Layout subsystem). + +##### Callers + +The Rendering subsystem selects `StateTransitionViewLayoutStrategy` when rendering a State +Transition View. No other unit calls it directly. diff --git a/docs/design/sysml2-tools-core/rendering/internal.md b/docs/design/sysml2-tools-core/rendering/internal.md new file mode 100644 index 00000000..ebd446aa --- /dev/null +++ b/docs/design/sysml2-tools-core/rendering/internal.md @@ -0,0 +1,27 @@ +### Rendering Internal Subsystem + +#### Overview + +The Internal sub-subsystem of Rendering holds the implementation details that the public +rendering pipeline relies on but does not expose: selecting a layout strategy for each view +and filtering out standard-library elements. It contains the `DiagramTypeRouter` unit and the +`StdlibFilter` helper. + +#### Interfaces + +The components are internal and are consumed only by other Core types. `DiagramTypeRouter` +exposes a single `GetStrategy` method returning an `ILayoutStrategy`. `StdlibFilter` exposes a +predicate used by the view strategies to decide whether an element belongs to the standard +library. + +#### Design + +`DiagramTypeRouter` inspects a view's name and declared supertype names for a recognized view +kind (interconnection, state transition, action flow, grid/matrix, browser/tree, sequence) and +returns the matching strategy, defaulting to the general view strategy. `StdlibFilter` answers +whether a qualified name belongs to the set of standard-library names carried by the workspace, +so the view strategies can omit those elements. `StdlibFilter` is a stateless predicate helper +with no behavior of its own beyond the membership test, and is verified indirectly through the +view-strategy tests that assert standard-library elements are excluded; it is therefore +documented here rather than as a separate unit. The `DiagramTypeRouter` unit is described in +its own chapter. diff --git a/docs/design/sysml2-tools-core/rendering/internal/diagram-type-router.md b/docs/design/sysml2-tools-core/rendering/internal/diagram-type-router.md new file mode 100644 index 00000000..d34f1774 --- /dev/null +++ b/docs/design/sysml2-tools-core/rendering/internal/diagram-type-router.md @@ -0,0 +1,41 @@ +#### DiagramTypeRouter + +##### Purpose + +`DiagramTypeRouter` selects the `ILayoutStrategy` to use for a given view, so the renderer can +treat every view kind uniformly. It is the single dispatch point that maps a view to its +diagram type. + +##### Data Model + +`DiagramTypeRouter` is a static class with no instance state. Its input is a view node and the +workspace; its output is an `ILayoutStrategy` instance, with an `out string?` carrying a +diagnostic message when no strategy can be determined. + +##### Key Methods + +###### `GetStrategy(viewNode, workspace, out unsupportedMessage)` + +Returns the strategy for the view. When the node is a view, the router tests the view's name +and its declared supertype names (case-insensitively) for a recognized view-kind marker, in a +fixed priority order: Interconnection, then StateTransition/State, then ActionFlow/Action, then +Grid/Matrix/Tabular, then Browser/Tree, then Sequence. The first marker that matches selects the +corresponding strategy. When no marker matches β€” or the node is not a view β€” the router returns +the general view strategy. The fixed order resolves views that carry more than one marker +deterministically. + +##### Error Handling + +The router never throws for an unrecognized view; it returns the general view strategy as a +safe default. The `unsupportedMessage` out-parameter is reserved for future view kinds that +cannot be rendered; it is currently always null because every view resolves to a strategy. + +##### Dependencies + +- The view-strategy units in the Layout Internal subsystem (the strategies it returns). +- `SysmlViewNode` and `SysmlWorkspace` (Semantic subsystem) for the view's identity. + +##### Callers + +`DiagramRenderer`, which calls `GetStrategy` once per view before building and rendering its +layout. diff --git a/docs/gallery/README.md b/docs/gallery/README.md new file mode 100644 index 00000000..533101b1 --- /dev/null +++ b/docs/gallery/README.md @@ -0,0 +1,118 @@ +# SysML2Tools Diagram Gallery + +This gallery showcases every diagram view type that SysML2Tools can render, each +generated from an interesting example system. Every model is rendered to both +**PNG** (raster, in [`png/`](png/)) and **SVG** (vector, in [`svg/`](svg/)). + +All diagrams are produced by the `sysml2tools render` command directly from the +SysML v2 textual models in [`models/`](models/) β€” no manual layout. The view kind +is selected automatically from each view's name (see the +[rendering roadmap](../../ROADMAP.md) for the dispatch rules). + +To regenerate the gallery, run for each model: + +```pwsh +sysml2tools render .sysml --format png --output docs/gallery/png +sysml2tools render .sysml --format svg --output docs/gallery/svg +``` + +--- + +## 1. General View β€” Quadcopter Drone + +Shows every definition kind (part, port, interface, attribute, enumeration, +requirement) grouped in a package folder, with typed compartments (attributes, +ports, parts) and specialization edges. + +Model: [`models/01-drone-general.sysml`](models/01-drone-general.sysml) Β· +SVG: [`svg/DroneGeneralView.svg`](svg/DroneGeneralView.svg) + +![Drone General View](png/DroneGeneralView.png) + +--- + +## 2. Interconnection View β€” Desktop Workstation + +Shows the internal structure of a part: nested part usages placed by the +force-directed engine, ports on box boundaries, and connectors routed between them. +The motherboard sits at the hub of the component connections. + +Model: [`models/02-computer-interconnection.sysml`](models/02-computer-interconnection.sysml) Β· +SVG: [`svg/WorkstationInterconnectionView.svg`](svg/WorkstationInterconnectionView.svg) + +![Workstation Interconnection View](png/WorkstationInterconnectionView.png) + +--- + +## 3. State Transition View β€” Elevator Controller + +Shows states placed by the force-directed engine, an initial pseudo-state, and +guarded transitions with `[guard]` labels. + +Model: [`models/03-elevator-state.sysml`](models/03-elevator-state.sysml) Β· +SVG: [`svg/ElevatorStateTransitionView.svg`](svg/ElevatorStateTransitionView.svg) + +![Elevator State Transition View](png/ElevatorStateTransitionView.png) + +--- + +## 4. Action Flow View β€” CI/CD Pipeline + +Shows actions arranged top-to-bottom in layers by the Sugiyama-style layered engine, +with a start node, a done node, and a quality-gate branch and join. + +Model: [`models/04-pipeline-action-flow.sysml`](models/04-pipeline-action-flow.sysml) Β· +SVG: [`svg/PipelineActionFlowView.svg`](svg/PipelineActionFlowView.svg) + +![Pipeline Action Flow View](png/PipelineActionFlowView.png) + +--- + +## 5. Sequence View β€” OAuth 2.0 Login + +Shows lifelines for each participant and the ordered messages exchanged during an +OAuth authorization-code login. + +Model: [`models/05-oauth-sequence.sysml`](models/05-oauth-sequence.sysml) Β· +SVG: [`svg/OAuthSequenceView.svg`](svg/OAuthSequenceView.svg) + +![OAuth Sequence View](png/OAuthSequenceView.png) + +--- + +## 6. Grid View β€” Vehicle Taxonomy + +Shows a specialization relationship matrix: a cell is marked where the row +definition specializes the column definition. + +Model: [`models/06-vehicle-grid.sysml`](models/06-vehicle-grid.sysml) Β· +SVG: [`svg/TaxonomyMatrixView.svg`](svg/TaxonomyMatrixView.svg) + +![Vehicle Taxonomy Matrix View](png/TaxonomyMatrixView.png) + +--- + +## 7. Browser View β€” Avionics System + +Shows the membership hierarchy of nested packages and definitions as an indented +tree with parent-to-child connectors. + +Model: [`models/07-avionics-browser.sysml`](models/07-avionics-browser.sysml) Β· +SVG: [`svg/AvionicsBrowserView.svg`](svg/AvionicsBrowserView.svg) + +![Avionics Browser View](png/AvionicsBrowserView.png) + +--- + +## View coverage + +| # | View type | Example system | Status | +| --- | --- | --- | --- | +| 1 | General View | Quadcopter Drone | βœ… | +| 2 | Interconnection View | Desktop Workstation | βœ… | +| 3 | State Transition View | Elevator Controller | βœ… | +| 4 | Action Flow View | CI/CD Pipeline | βœ… | +| 5 | Sequence View | OAuth 2.0 Login | βœ… | +| 6 | Grid View | Vehicle Taxonomy | βœ… | +| 7 | Browser View | Avionics System | βœ… | +| 8 | Geometry View | β€” | Deferred (requires spatial coordinate data) | diff --git a/docs/gallery/models/01-drone-general.sysml b/docs/gallery/models/01-drone-general.sysml new file mode 100644 index 00000000..3a49ab73 --- /dev/null +++ b/docs/gallery/models/01-drone-general.sysml @@ -0,0 +1,67 @@ +package QuadcopterDrone { + + // ===== Interface and port definitions ===== + interface def PowerBus; + interface def DataBus; + port def PowerPort; + port def TelemetryPort; + port def MotorControlPort; + port def SensorPort; + + // ===== Attribute and enumeration definitions ===== + attribute def Mass; + attribute def Voltage; + enum def FlightMode; + + // ===== Core part definitions ===== + part def Battery { + attribute capacity : Voltage; + port output : PowerPort; + } + + part def FlightController { + attribute mode : FlightMode; + port power : PowerPort; + port telemetry : TelemetryPort; + port motors : MotorControlPort; + port sensors : SensorPort; + } + + part def Motor { + attribute maxThrust : Mass; + port control : MotorControlPort; + } + + part def Propeller; + + part def ImuSensor { + port data : SensorPort; + } + + part def GpsSensor { + port data : SensorPort; + } + + part def Frame; + + // ===== Specializations ===== + part def RacingMotor :> Motor; + part def EnduranceBattery :> Battery; + + // ===== The integrated drone ===== + part def Drone { + attribute totalMass : Mass; + part airframe : Frame; + part battery : Battery; + part controller : FlightController; + part motors : Motor[4]; + part propellers : Propeller[4]; + part imu : ImuSensor; + part gps : GpsSensor; + } + + // ===== A requirement ===== + requirement def FlightTimeRequirement; + + view def DroneGeneralView {} +} diff --git a/docs/gallery/models/02-computer-interconnection.sysml b/docs/gallery/models/02-computer-interconnection.sysml new file mode 100644 index 00000000..a48276d2 --- /dev/null +++ b/docs/gallery/models/02-computer-interconnection.sysml @@ -0,0 +1,32 @@ +package DesktopComputer { + + part def Cpu; + part def Ram; + part def Gpu; + part def Ssd; + part def PowerSupply; + part def NetworkCard; + part def Motherboard; + + // The motherboard interconnects all components. + part def Workstation { + part cpu : Cpu; + part memory : Ram; + part graphics : Gpu; + part storage : Ssd; + part psu : PowerSupply; + part network : NetworkCard; + part board : Motherboard; + + connection c1 connect board to cpu; + connection c2 connect board to memory; + connection c3 connect board to graphics; + connection c4 connect board to storage; + connection c5 connect board to network; + connection c6 connect psu to board; + connection c7 connect psu to graphics; + connection c8 connect cpu to memory; + } + + view def WorkstationInterconnectionView {} +} diff --git a/docs/gallery/models/03-elevator-state.sysml b/docs/gallery/models/03-elevator-state.sysml new file mode 100644 index 00000000..a626d271 --- /dev/null +++ b/docs/gallery/models/03-elevator-state.sysml @@ -0,0 +1,23 @@ +package ElevatorController { + + // An elevator control state machine. + state def ElevatorStates { + state idle; + state doorsOpening; + state doorsOpen; + state doorsClosing; + state movingUp; + state movingDown; + + transition first idle if callReceived then doorsClosing; + transition first doorsClosing if doorsClosed then movingUp; + transition first movingUp if atFloor then doorsOpening; + transition first movingDown if atFloor then doorsOpening; + transition first doorsOpening if doorsAreOpen then doorsOpen; + transition first doorsOpen if timeout then doorsClosing; + transition first doorsClosing if goingDown then movingDown; + transition first movingUp if idleTimeout then idle; + } + + view def ElevatorStateTransitionView {} +} diff --git a/docs/gallery/models/04-pipeline-action-flow.sysml b/docs/gallery/models/04-pipeline-action-flow.sysml new file mode 100644 index 00000000..2a9f5559 --- /dev/null +++ b/docs/gallery/models/04-pipeline-action-flow.sysml @@ -0,0 +1,35 @@ +package CiCdPipeline { + + // A continuous-integration / deployment action flow with a quality gate branch. + action def Pipeline { + action checkout; + action restoreDependencies; + action build; + action unitTest; + action integrationTest; + action securityScan; + action qualityGate; + action deployStaging; + action smokeTest; + action deployProduction; + action rollback; + action notifySuccess; + action notifyFailure; + + first checkout then restoreDependencies; + first restoreDependencies then build; + first build then unitTest; + first build then securityScan; + first unitTest then integrationTest; + first integrationTest then qualityGate; + first securityScan then qualityGate; + first qualityGate then deployStaging; + first deployStaging then smokeTest; + first smokeTest then deployProduction; + first smokeTest then rollback; + first deployProduction then notifySuccess; + first rollback then notifyFailure; + } + + view def PipelineActionFlowView {} +} diff --git a/docs/gallery/models/05-oauth-sequence.sysml b/docs/gallery/models/05-oauth-sequence.sysml new file mode 100644 index 00000000..d5abf9bf --- /dev/null +++ b/docs/gallery/models/05-oauth-sequence.sysml @@ -0,0 +1,38 @@ +package OAuthLogin { + + // An OAuth 2.0 authorization-code login flow across four participants. + part def AuthorizationFlow { + part user { + event occurrence clickLogin; + then event occurrence enterCredentials; + then event occurrence seeApp; + } + part browser { + event occurrence requestPage; + then event occurrence redirectToAuth; + then event occurrence sendCode; + then event occurrence loadApp; + } + part authServer { + event occurrence showLogin; + then event occurrence issueCode; + then event occurrence issueToken; + } + part resourceServer { + event occurrence validateToken; + then event occurrence returnData; + } + + message openApp from user.clickLogin to browser.requestPage; + message redirect from browser.redirectToAuth to authServer.showLogin; + message promptCredentials from authServer.showLogin to user.enterCredentials; + message submitCredentials from user.enterCredentials to authServer.issueCode; + message authCode from authServer.issueCode to browser.sendCode; + message exchangeCode from browser.sendCode to authServer.issueToken; + message accessToken from authServer.issueToken to browser.loadApp; + message fetchResource from browser.loadApp to resourceServer.validateToken; + message resourceData from resourceServer.returnData to user.seeApp; + } + + view def OAuthSequenceView {} +} diff --git a/docs/gallery/models/06-vehicle-grid.sysml b/docs/gallery/models/06-vehicle-grid.sysml new file mode 100644 index 00000000..993f0d7a --- /dev/null +++ b/docs/gallery/models/06-vehicle-grid.sysml @@ -0,0 +1,25 @@ +package VehicleTaxonomy { + + // A vehicle classification hierarchy rendered as a specialization matrix. + part def Vehicle; + + part def LandVehicle :> Vehicle; + part def WaterVehicle :> Vehicle; + part def AirVehicle :> Vehicle; + + part def Car :> LandVehicle; + part def Truck :> LandVehicle; + part def Motorcycle :> LandVehicle; + + part def Boat :> WaterVehicle; + part def Submarine :> WaterVehicle; + + part def Airplane :> AirVehicle; + part def Helicopter :> AirVehicle; + + part def SportsCar :> Car; + part def Sedan :> Car; + part def PickupTruck :> Truck; + + view def TaxonomyMatrixView {} +} diff --git a/docs/gallery/models/07-avionics-browser.sysml b/docs/gallery/models/07-avionics-browser.sysml new file mode 100644 index 00000000..02c0e4c4 --- /dev/null +++ b/docs/gallery/models/07-avionics-browser.sysml @@ -0,0 +1,37 @@ +package AvionicsSystem { + + package Navigation { + part def InertialUnit; + part def GpsReceiver; + part def AirDataComputer; + package Sensors { + part def Accelerometer; + part def Gyroscope; + part def Magnetometer; + } + } + + package FlightControls { + part def AutopilotComputer; + part def Actuator; + port def ControlPort; + package Surfaces { + part def Aileron; + part def Elevator; + part def Rudder; + } + } + + package Communications { + part def RadioTransceiver; + part def Transponder; + interface def DataLink; + } + + package Displays { + part def PrimaryFlightDisplay; + part def NavigationDisplay; + } + + view def AvionicsBrowserView {} +} diff --git a/docs/gallery/png/AvionicsBrowserView.png b/docs/gallery/png/AvionicsBrowserView.png new file mode 100644 index 00000000..91b6b1c1 Binary files /dev/null and b/docs/gallery/png/AvionicsBrowserView.png differ diff --git a/docs/gallery/png/DroneGeneralView.png b/docs/gallery/png/DroneGeneralView.png new file mode 100644 index 00000000..867d859d Binary files /dev/null and b/docs/gallery/png/DroneGeneralView.png differ diff --git a/docs/gallery/png/ElevatorStateTransitionView.png b/docs/gallery/png/ElevatorStateTransitionView.png new file mode 100644 index 00000000..62dcac20 Binary files /dev/null and b/docs/gallery/png/ElevatorStateTransitionView.png differ diff --git a/docs/gallery/png/OAuthSequenceView.png b/docs/gallery/png/OAuthSequenceView.png new file mode 100644 index 00000000..496f50f4 Binary files /dev/null and b/docs/gallery/png/OAuthSequenceView.png differ diff --git a/docs/gallery/png/PipelineActionFlowView.png b/docs/gallery/png/PipelineActionFlowView.png new file mode 100644 index 00000000..0cda0f15 Binary files /dev/null and b/docs/gallery/png/PipelineActionFlowView.png differ diff --git a/docs/gallery/png/TaxonomyMatrixView.png b/docs/gallery/png/TaxonomyMatrixView.png new file mode 100644 index 00000000..5599af41 Binary files /dev/null and b/docs/gallery/png/TaxonomyMatrixView.png differ diff --git a/docs/gallery/png/WorkstationInterconnectionView.png b/docs/gallery/png/WorkstationInterconnectionView.png new file mode 100644 index 00000000..05133ab6 Binary files /dev/null and b/docs/gallery/png/WorkstationInterconnectionView.png differ diff --git a/docs/gallery/svg/AvionicsBrowserView.svg b/docs/gallery/svg/AvionicsBrowserView.svg new file mode 100644 index 00000000..b8245e15 --- /dev/null +++ b/docs/gallery/svg/AvionicsBrowserView.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + package AvionicsSystem + + + view def AvionicsBrowserView + + + package Communications + + + interface def DataLink + + + part def RadioTransceiver + + + part def Transponder + + + package Displays + + + part def NavigationDisplay + + + part def PrimaryFlightDisplay + + + package FlightControls + + + part def Actuator + + + part def AutopilotComputer + + + port def ControlPort + + + package Surfaces + + + part def Aileron + + + part def Elevator + + + part def Rudder + + + package Navigation + + + part def AirDataComputer + + + part def GpsReceiver + + + part def InertialUnit + + + package Sensors + + + part def Accelerometer + + + part def Gyroscope + + + part def Magnetometer + diff --git a/docs/gallery/svg/DroneGeneralView.svg b/docs/gallery/svg/DroneGeneralView.svg new file mode 100644 index 00000000..e686d041 --- /dev/null +++ b/docs/gallery/svg/DroneGeneralView.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Β«packageΒ» + QuadcopterDrone + + Β«interface defΒ» + PowerBus + + Β«interface defΒ» + DataBus + + Β«port defΒ» + PowerPort + + Β«port defΒ» + TelemetryPort + + Β«port defΒ» + MotorControlPort + + Β«port defΒ» + SensorPort + + Β«attribute defΒ» + Mass + + Β«attribute defΒ» + Voltage + + Β«enum defΒ» + FlightMode + + Β«part defΒ» + Battery + + attributes + capacity : Voltage + + ports + output : PowerPort + + Β«part defΒ» + FlightController + + attributes + mode : FlightMode + + ports + power : PowerPort + telemetry : TelemetryPort + motors : MotorControlPort + sensors : SensorPort + + Β«part defΒ» + Motor + + attributes + maxThrust : Mass + + ports + control : MotorControlPort + + Β«part defΒ» + Propeller + + Β«part defΒ» + ImuSensor + + ports + data : SensorPort + + Β«part defΒ» + GpsSensor + + ports + data : SensorPort + + Β«part defΒ» + Frame + + Β«part defΒ» + RacingMotor + + Β«part defΒ» + EnduranceBattery + + Β«part defΒ» + Drone + + attributes + totalMass : Mass + + parts + airframe : Frame + battery : Battery + controller : FlightController + motors : Motor [4] + propellers : Propeller [4] + imu : ImuSensor + gps : GpsSensor + + Β«requirement defΒ» + FlightTimeRequirement + + + diff --git a/docs/gallery/svg/ElevatorStateTransitionView.svg b/docs/gallery/svg/ElevatorStateTransitionView.svg new file mode 100644 index 00000000..54ba39f8 --- /dev/null +++ b/docs/gallery/svg/ElevatorStateTransitionView.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Β«stateΒ» + idle + + Β«stateΒ» + doorsOpening + + Β«stateΒ» + doorsOpen + + Β«stateΒ» + doorsClosing + + Β«stateΒ» + movingUp + + Β«stateΒ» + movingDown + + + + + + + + + + + [callReceived] + [doorsClosed] + [atFloor] + [atFloor] + [doorsAreOpen] + [timeout] + [goingDown] + [idleTimeout] + diff --git a/docs/gallery/svg/OAuthSequenceView.svg b/docs/gallery/svg/OAuthSequenceView.svg new file mode 100644 index 00000000..c05271e7 --- /dev/null +++ b/docs/gallery/svg/OAuthSequenceView.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + user + + + browser + + + authServer + + + resourceServer + + + + + + + + + + + openApp + redirect + promptCredentials + submitCredentials + authCode + exchangeCode + accessToken + fetchResource + resourceData + diff --git a/docs/gallery/svg/PipelineActionFlowView.svg b/docs/gallery/svg/PipelineActionFlowView.svg new file mode 100644 index 00000000..9acd6d07 --- /dev/null +++ b/docs/gallery/svg/PipelineActionFlowView.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Β«actionΒ» + checkout + + Β«actionΒ» + restoreDependencies + + Β«actionΒ» + build + + Β«actionΒ» + unitTest + + Β«actionΒ» + integrationTest + + Β«actionΒ» + securityScan + + Β«actionΒ» + qualityGate + + Β«actionΒ» + deployStaging + + Β«actionΒ» + smokeTest + + Β«actionΒ» + deployProduction + + Β«actionΒ» + rollback + + Β«actionΒ» + notifySuccess + + Β«actionΒ» + notifyFailure + + + + + + + + + + + + + + + + + + + + diff --git a/docs/gallery/svg/TaxonomyMatrixView.svg b/docs/gallery/svg/TaxonomyMatrixView.svg new file mode 100644 index 00000000..db4cda71 --- /dev/null +++ b/docs/gallery/svg/TaxonomyMatrixView.svg @@ -0,0 +1,476 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AirVehicle + + Airplane + + Boat + + Car + + Helicopter + + LandVehicle + + Motorcycle + + PickupTruck + + Sedan + + SportsCar + + Submarine + + Truck + + Vehicle + + WaterVehicle + + AirVehicle + + + + + + + + + + + + + + + + + + + + + + + + + + X + + + + Airplane + + X + + + + + + + + + + + + + + + + + + + + + + + + + + + + Boat + + + + + + + + + + + + + + + + + + + + + + + + + + + + X + + Car + + + + + + + + + + + + X + + + + + + + + + + + + + + + + + + Helicopter + + X + + + + + + + + + + + + + + + + + + + + + + + + + + + + LandVehicle + + + + + + + + + + + + + + + + + + + + + + + + + + X + + + + Motorcycle + + + + + + + + + + + + X + + + + + + + + + + + + + + + + + + PickupTruck + + + + + + + + + + + + + + + + + + + + + + + + X + + + + + + Sedan + + + + + + + + X + + + + + + + + + + + + + + + + + + + + + + SportsCar + + + + + + + + X + + + + + + + + + + + + + + + + + + + + + + Submarine + + + + + + + + + + + + + + + + + + + + + + + + + + + + X + + Truck + + + + + + + + + + + + X + + + + + + + + + + + + + + + + + + Vehicle + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WaterVehicle + + + + + + + + + + + + + + + + + + + + + + + + + + X + + + diff --git a/docs/gallery/svg/WorkstationInterconnectionView.svg b/docs/gallery/svg/WorkstationInterconnectionView.svg new file mode 100644 index 00000000..21325f9a --- /dev/null +++ b/docs/gallery/svg/WorkstationInterconnectionView.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Β«part defΒ» + Workstation + + Β«partΒ» + cpu : Cpu + + Β«partΒ» + memory : Ram + + Β«partΒ» + graphics : Gpu + + Β«partΒ» + storage : Ssd + + Β«partΒ» + psu : PowerSupply + + Β«partΒ» + network : NetworkCard + + Β«partΒ» + board : Motherboard + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/reqstream/sysml2-tools-core/layout/connector-label-placer.yaml b/docs/reqstream/sysml2-tools-core/layout/connector-label-placer.yaml new file mode 100644 index 00000000..7217baa6 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/connector-label-placer.yaml @@ -0,0 +1,41 @@ +--- +# ConnectorLabelPlacer Unit Requirements +# +# PURPOSE: +# - Define requirements for the ConnectorLabelPlacer unit +# - ConnectorLabelPlacer computes screen positions for connector (midpoint) labels +# - Requirements describe observable placement properties, not the placement algorithm + +sections: + - title: ConnectorLabelPlacer Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-ConnectorLabelPlacer-LabelledLinesOnly + title: >- + ConnectorLabelPlacer shall compute a position for every line that carries a label and + shall omit lines that carry no label. + justification: | + Only labelled connectors need a label position. Omitting unlabelled lines keeps the + result limited to the connectors that are actually annotated. + tests: + - Place_LineWithoutLabel_IsOmitted + + - id: SysML2Tools-Core-Layout-ConnectorLabelPlacer-PreferredPlacement + title: >- + ConnectorLabelPlacer shall place a connector's label at the midpoint of the connector's + longest segment. + justification: | + The longest segment is the most open run of a connector, so a label centered there is the + least likely to overlap boxes or bends and reads as belonging to that connector. + tests: + - Place_SingleLine_UsesLongestSegmentMidpoint + + - id: SysML2Tools-Core-Layout-ConnectorLabelPlacer-Separation + title: >- + When two labels would otherwise overlap, ConnectorLabelPlacer shall separate them so the + placed labels do not collide, resolving the order deterministically from the input order. + justification: | + Overlapping labels are unreadable. Moving a colliding label clear of the ones already + placed keeps every label legible, and resolving collisions by input order makes the + result reproducible across renderers. + tests: + - Place_CollidingLabels_AreSeparated diff --git a/docs/reqstream/sysml2-tools-core/layout/engine.yaml b/docs/reqstream/sysml2-tools-core/layout/engine.yaml new file mode 100644 index 00000000..9f79c056 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/engine.yaml @@ -0,0 +1,45 @@ +--- +# Layout Engine Subsystem Requirements +# +# PURPOSE: +# - Define requirements for the Engine subsystem of the Layout subsystem +# - Engines are reusable geometric layout components independent of the SysML model +# - Requirements describe observable geometric behavior, not the algorithms used + +sections: + - title: Layout Engine Subsystem Requirements + requirements: + - id: SysML2Tools-Core-Layout-Engine-ModelIndependent + title: >- + The layout engines shall compute geometry from plain geometric inputs (sizes, + edges, anchors, and connections) without reference to the SysML semantic model. + justification: | + Keeping the engines independent of the semantic model allows every view strategy + to reuse the same engines and allows each engine to be tested in isolation with + synthetic geometric input. + tests: + - Route_NoObstacles_ProducesOrthogonalPath + - Place_ConnectedGraph_AllRectsWithinBounds + - Place_Chain_AssignsIncreasingLayers + + - id: SysML2Tools-Core-Layout-Engine-NonOverlapping + title: >- + The placement engines shall position boxes so that boxes placed in the same + region do not overlap one another. + justification: | + Overlapping boxes produce unreadable diagrams. Non-overlap is an observable + property of the produced geometry that any placement engine must guarantee. + tests: + - Place_SameLayerNodes_DoNotOverlap + - Place_ConnectedGraph_ProducesNoOverlaps + + - id: SysML2Tools-Core-Layout-Engine-Deterministic + title: >- + Given identical input, the layout engines shall produce identical geometry on + every invocation. + justification: | + Deterministic output is required for reproducible diagrams, stable golden-image + tests, and meaningful diffs between renders of the same model. + tests: + - Place_SameInput_IsDeterministic + - Route_AlignedEndpoints_ProducesStraightLine diff --git a/docs/reqstream/sysml2-tools-core/layout/engine/channel-router.yaml b/docs/reqstream/sysml2-tools-core/layout/engine/channel-router.yaml new file mode 100644 index 00000000..eedeb545 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/engine/channel-router.yaml @@ -0,0 +1,67 @@ +--- +# ChannelRouter Unit Requirements +# +# PURPOSE: +# - Define requirements for the ChannelRouter unit +# - ChannelRouter routes an orthogonal connector between two anchor points +# - Requirements describe observable path properties, not the routing algorithm + +sections: + - title: ChannelRouter Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Engine-ChannelRouter-Orthogonal + title: >- + ChannelRouter 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 tool renders. Consecutive waypoints therefore always share an + X or a Y coordinate. + tests: + - Route_NoObstacles_ProducesOrthogonalPath + - Route_AlignedEndpoints_ProducesStraightLine + + - id: SysML2Tools-Core-Layout-Engine-ChannelRouter-AvoidObstacles + title: >- + When an obstacle-free path exists, ChannelRouter 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: SysML2Tools-Core-Layout-Engine-ChannelRouter-Clearance + title: >- + When an obstacle-free path is achievable, ChannelRouter 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: SysML2Tools-Core-Layout-Engine-ChannelRouter-PerpendicularEnds + title: >- + When an anchor is given with the box side it sits on, ChannelRouter 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: SysML2Tools-Core-Layout-Engine-ChannelRouter-CrossingStatus + title: >- + ChannelRouter 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 diff --git a/docs/reqstream/sysml2-tools-core/layout/engine/containment-packer.yaml b/docs/reqstream/sysml2-tools-core/layout/engine/containment-packer.yaml new file mode 100644 index 00000000..602331cc --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/engine/containment-packer.yaml @@ -0,0 +1,81 @@ +--- +# ContainmentPacker Unit Requirements +# +# PURPOSE: +# - Define requirements for the ContainmentPacker unit +# - ContainmentPacker arranges variable-size items into rows within a width budget +# - Requirements describe observable placement properties, not the packing algorithm + +sections: + - title: ContainmentPacker Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Engine-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: SysML2Tools-Core-Layout-Engine-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: SysML2Tools-Core-Layout-Engine-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: SysML2Tools-Core-Layout-Engine-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: SysML2Tools-Core-Layout-Engine-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: SysML2Tools-Core-Layout-Engine-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: SysML2Tools-Core-Layout-Engine-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/sysml2-tools-core/layout/engine/force-directed-engine.yaml b/docs/reqstream/sysml2-tools-core/layout/engine/force-directed-engine.yaml new file mode 100644 index 00000000..bd0082ec --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/engine/force-directed-engine.yaml @@ -0,0 +1,61 @@ +--- +# ForceDirectedEngine Unit Requirements +# +# PURPOSE: +# - Define requirements for the ForceDirectedEngine unit +# - ForceDirectedEngine arranges connected nodes into a spread-out, non-overlapping placement +# - Requirements describe observable placement properties, not the layout algorithm + +sections: + - title: ForceDirectedEngine Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Engine-ForceDirectedEngine-NoOverlap + title: >- + ForceDirectedEngine shall position the supplied nodes so that no two node rectangles + overlap. + justification: | + Overlapping boxes hide one another and make a diagram unreadable. Whatever spacing the + caller requests, the final placement must leave every node rectangle disjoint from every + other. + tests: + - Place_ConnectedGraph_ProducesNoOverlaps + + - id: SysML2Tools-Core-Layout-Engine-ForceDirectedEngine-WithinBounds + title: >- + ForceDirectedEngine shall place every node rectangle within the bounds of the region size + it reports. + justification: | + Callers size the drawing surface from the reported region width and height. Every placed + rectangle must therefore lie inside those bounds so that no node is clipped or drawn + outside the canvas. + tests: + - Place_ConnectedGraph_AllRectsWithinBounds + + - id: SysML2Tools-Core-Layout-Engine-ForceDirectedEngine-Deterministic + title: >- + ForceDirectedEngine shall produce identical placements for identical inputs. + justification: | + Reproducible diagrams are required so that regenerating a view from unchanged input does + not produce spurious differences in version control or review. + tests: + - Place_SameInput_IsDeterministic + + - id: SysML2Tools-Core-Layout-Engine-ForceDirectedEngine-EmptyInput + title: >- + When given no nodes, ForceDirectedEngine shall return an empty placement whose region + consists solely of the requested padding. + justification: | + An empty graph is a valid degenerate case. The engine must return a well-formed, empty + result sized only by the padding rather than failing. + tests: + - Place_EmptyList_ReturnsPaddingOnlyRegion + + - id: SysML2Tools-Core-Layout-Engine-ForceDirectedEngine-SingleNode + title: >- + When given a single node, ForceDirectedEngine shall place that node at the padding origin + of the region. + justification: | + A lone node has no neighbors to balance against, so it must sit at a predictable origin + offset by the requested padding rather than at an arbitrary position. + tests: + - Place_SingleNode_PositionsAtPadding diff --git a/docs/reqstream/sysml2-tools-core/layout/engine/layered-layout-engine.yaml b/docs/reqstream/sysml2-tools-core/layout/engine/layered-layout-engine.yaml new file mode 100644 index 00000000..ba3f1f7e --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/engine/layered-layout-engine.yaml @@ -0,0 +1,52 @@ +--- +# LayeredLayoutEngine Unit Requirements +# +# PURPOSE: +# - Define requirements for the LayeredLayoutEngine unit +# - LayeredLayoutEngine arranges a directed graph into a top-to-bottom layered flow +# - Requirements describe observable placement properties, not the layout algorithm + +sections: + - title: LayeredLayoutEngine Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Engine-LayeredLayoutEngine-DownwardFlow + title: >- + LayeredLayoutEngine shall arrange a directed graph into layers so that each edge runs from + a lower layer to a higher layer, with lower layers positioned above higher layers. + justification: | + A directed diagram reads as a flow only when every connection advances in a single + direction. Source nodes must sit in a smaller layer, and therefore higher on the canvas, + than the nodes they point to. + tests: + - Place_Chain_AssignsIncreasingLayers + - Place_Branching_EdgesPointDownward + + - id: SysML2Tools-Core-Layout-Engine-LayeredLayoutEngine-NoOverlap + title: >- + LayeredLayoutEngine shall position nodes that share a layer so that their rectangles do + not overlap. + justification: | + Nodes on the same layer are drawn side by side; if they overlapped they would be + unreadable. Each layer's nodes must be separated horizontally. + tests: + - Place_SameLayerNodes_DoNotOverlap + + - id: SysML2Tools-Core-Layout-Engine-LayeredLayoutEngine-CyclesHandled + title: >- + When the directed graph contains a cycle, LayeredLayoutEngine shall still produce a + complete placement with every node positioned within the reported region bounds. + justification: | + Real models contain feedback loops. The engine must not fail to terminate or drop nodes + when a cycle is present; every node must still be placed inside the region. + tests: + - Place_Cycle_TerminatesAndPlacesAllNodes + + - id: SysML2Tools-Core-Layout-Engine-LayeredLayoutEngine-EmptyInput + title: >- + When given no nodes, LayeredLayoutEngine shall return an empty placement whose region + consists solely of the requested padding. + justification: | + An empty graph is a valid degenerate case. The engine must return a well-formed, empty + result sized only by the padding rather than failing. + tests: + - Place_EmptyList_ReturnsPaddingOnlyRegion diff --git a/docs/reqstream/sysml2-tools-core/layout/engine/port-assigner.yaml b/docs/reqstream/sysml2-tools-core/layout/engine/port-assigner.yaml new file mode 100644 index 00000000..589ea46a --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/engine/port-assigner.yaml @@ -0,0 +1,50 @@ +--- +# PortAssigner Unit Requirements +# +# PURPOSE: +# - Define requirements for the PortAssigner unit +# - PortAssigner places connection ports on the sides of a box +# - Requirements describe observable placement properties, not the assignment algorithm + +sections: + - title: PortAssigner Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Engine-PortAssigner-SideFacingTarget + title: >- + PortAssigner shall place each port on the side of its box that faces the point its + connection travels toward. + justification: | + A port should leave its box on the side nearest the element it connects to, so the + connector takes the shortest, most readable route rather than wrapping around the box. + tests: + - Assign_SinglePort_ChoosesSideFacingTarget + + - id: SysML2Tools-Core-Layout-Engine-PortAssigner-OnBoundary + title: >- + PortAssigner shall place each port's centre on the boundary of the box side it is + assigned to. + justification: | + A port represents an attachment point on the box edge. Its centre must lie exactly on the + assigned side so that connectors meet the box outline rather than floating inside or + outside it. + tests: + - Assign_Port_CentreLiesOnBoxBoundary + + - id: SysML2Tools-Core-Layout-Engine-PortAssigner-EvenDistribution + title: >- + When multiple ports are assigned to the same box side, PortAssigner shall place them at + distinct, evenly spaced positions along that side. + justification: | + Ports stacked on top of one another are indistinguishable. Spreading the ports that share + a side across evenly spaced slots keeps each one visible and its connector separable. + tests: + - Assign_MultiplePortsSameSide_AreEvenlyDistributed + + - id: SysML2Tools-Core-Layout-Engine-PortAssigner-EmptyInput + title: >- + When given no port requests, PortAssigner shall return no placements. + justification: | + A box with no ports is a valid case. The assigner must return an empty result rather than + failing. + tests: + - Assign_Empty_ReturnsEmpty diff --git a/docs/reqstream/sysml2-tools-core/layout/internal.yaml b/docs/reqstream/sysml2-tools-core/layout/internal.yaml new file mode 100644 index 00000000..f6ae7f6a --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal.yaml @@ -0,0 +1,60 @@ +--- +# Layout Internal Subsystem Requirements +# +# PURPOSE: +# - Define requirements for the Internal subsystem of the Layout subsystem +# - The Internal subsystem holds the per-view layout strategies that turn the +# semantic workspace into a LayoutTree for one diagram view +# - Requirements describe observable layout behavior, not the placement algorithms + +sections: + - title: Layout Internal Subsystem Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-ViewLayout + title: >- + Each view layout strategy shall transform the semantic workspace into a layout tree + describing the diagram for its view type. + justification: | + The renderers consume only the layout tree, so every supported view depends on a + strategy that maps the relevant model elements to boxes, ports, lines, and markers. + The produced tree is the observable result that proves the mapping occurred. + tests: + - GeneralViewLayoutStrategy_BuildLayout_OneUserPartDef_ProducesLayoutBox + - InterconnectionView_BuildLayout_PartsAndConnections_ProducesBoxesPortsAndLines + - StateTransitionView_BuildLayout_StatesAndTransitions_ProducesBoxesBadgeAndLines + - ActionFlowView_BuildLayout_ActionsAndSuccessions_ProducesBoxesMarkersAndFlows + + - id: SysML2Tools-Core-Layout-Internal-EmptyCanvas + title: >- + When the workspace contains no elements relevant to a view, the view layout strategy + shall return a minimal empty canvas with no nodes. + justification: | + A view with nothing to show must still yield a well-formed, empty layout tree so the + renderer produces a valid (blank) diagram rather than failing. The empty node set is + the observable evidence of this behavior. + tests: + - GeneralViewLayoutStrategy_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas + - InterconnectionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas + - StateTransitionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas + - ActionFlowView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas + + - id: SysML2Tools-Core-Layout-Internal-StdlibExclusion + title: >- + The view layout strategies shall exclude standard-library elements from the produced + diagrams. + justification: | + Diagrams are meant to show the user's model, not the imported standard library. + Excluding standard-library elements keeps the rendered views focused and readable. + tests: + - GeneralViewLayoutStrategy_BuildLayout_StdlibOnlyWorkspace_ReturnsMinimalCanvas + - GeneralViewLayoutStrategy_BuildLayout_SeedStdlibNames_AreExcluded + + - id: SysML2Tools-Core-Layout-Internal-NonOverlapping + title: >- + The view layout strategies shall position the boxes of a diagram so that they do not + overlap one another. + justification: | + Overlapping boxes produce unreadable diagrams. Non-overlap is an observable property + of the produced geometry that every view layout must guarantee. + tests: + - InterconnectionView_BuildLayout_PartBoxes_DoNotOverlap diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml new file mode 100644 index 00000000..9f7db9a5 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml @@ -0,0 +1,42 @@ +--- +# ActionFlowViewLayoutStrategy Unit Requirements +# +# PURPOSE: +# - Define requirements for the ActionFlowViewLayoutStrategy unit +# - ActionFlowViewLayoutStrategy lays out an Action Flow View diagram +# - Requirements describe observable diagram content, not the placement algorithm + +sections: + - title: ActionFlowViewLayoutStrategy Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-Actions + title: >- + ActionFlowViewLayoutStrategy shall render a box for each action, a start marker entering + the initial actions, a done marker leaving the final actions, and a flow arrow for each + succession. + justification: | + An Action Flow View exists to show the actions of a behavior, where the flow starts and + ends, and how control passes between actions. The action boxes, start and done markers, + and flow arrows are the observable content the view must provide. + tests: + - ActionFlowView_BuildLayout_ActionsAndSuccessions_ProducesBoxesMarkersAndFlows + + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-TopToBottom + title: >- + ActionFlowViewLayoutStrategy shall place the target action of a succession below its + source action. + justification: | + Reading an action flow top-to-bottom is the diagram convention; placing each successor + below its predecessor makes the direction of control flow immediately apparent. + tests: + - ActionFlowView_BuildLayout_Successions_FlowTopToBottom + + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-EmptyCanvas + title: >- + When the workspace contains no action definition, ActionFlowViewLayoutStrategy shall + return a minimal empty canvas with no nodes. + justification: | + With no action definition there is nothing to draw; a well-formed empty canvas lets the + renderer produce a valid blank diagram rather than failing. + tests: + - ActionFlowView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml new file mode 100644 index 00000000..ed97b2b0 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml @@ -0,0 +1,32 @@ +--- +# BrowserViewLayoutStrategy Unit Requirements +# +# PURPOSE: +# - Define requirements for the BrowserViewLayoutStrategy unit +# - BrowserViewLayoutStrategy lays out a Browser View as an indented membership tree +# - Requirements describe the observable tree, not the arithmetic used + +sections: + - title: BrowserViewLayoutStrategy Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-BrowserViewLayoutStrategy-IndentedTree + title: >- + BrowserViewLayoutStrategy shall render the membership hierarchy as an indented tree of + rows in which a nested element is indented further than its containing element, with a + connector from each parent to its children. + justification: | + A browser view communicates containment. Indenting children beyond their parents and + drawing parent-to-child connectors makes the membership hierarchy immediately readable, + matching the tree presentation users expect from a model browser. + tests: + - BrowserView_BuildLayout_NestedElements_AreIndentedByDepth + + - id: SysML2Tools-Core-Layout-Internal-BrowserViewLayoutStrategy-EmptyInput + title: >- + When the workspace contains no user-defined elements, BrowserViewLayoutStrategy shall + produce an empty diagram. + justification: | + With no elements to show, an empty diagram avoids rendering an empty tree frame that + could be mistaken for missing content. + tests: + - BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml new file mode 100644 index 00000000..cb1c0a74 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml @@ -0,0 +1,76 @@ +--- +# GeneralViewLayoutStrategy Unit Requirements +# +# PURPOSE: +# - Define requirements for the GeneralViewLayoutStrategy unit +# - GeneralViewLayoutStrategy lays out a General View diagram from user definitions +# - Requirements describe observable diagram content, not the placement algorithm + +sections: + - title: GeneralViewLayoutStrategy Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Definitions + title: >- + GeneralViewLayoutStrategy shall render each user-defined definition as a labelled box + that carries the definition's keyword. + justification: | + A General View shows the user's declared definitions of every kind (part, port, + interface, action, and so on). Rendering each as a keyword-labelled box is the + observable content the view exists to provide. + tests: + - GeneralViewLayoutStrategy_BuildLayout_OneUserPartDef_ProducesLayoutBox + - GeneralViewLayoutStrategy_BuildLayout_MixedDefinitionKinds_RendersAllWithKeywords + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-PackageGrouping + title: >- + GeneralViewLayoutStrategy shall group the definitions that belong to a package within a + folder-shaped container box labelled with the package name. + justification: | + Grouping a package's definitions inside a labelled folder communicates ownership and + keeps related definitions visually together, which is essential for readable diagrams + of larger models. + tests: + - GeneralViewLayoutStrategy_BuildLayout_PackagedDefinitions_ProducesFolderBox + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Compartments + title: >- + GeneralViewLayoutStrategy shall populate a definition box with compartments listing the + definition's owned usages. + justification: | + The usages a definition owns (attributes, ports, parts, and so on) are part of its + shown content. Listing them in compartments lets a reader see a definition's features + without opening another view. + tests: + - GeneralViewLayoutStrategy_BuildLayout_DefinitionWithUsages_ProducesCompartments + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Specialization + title: >- + GeneralViewLayoutStrategy shall connect a specialized definition to each of its + supertypes present in the workspace with a line ending in an open arrowhead at the + supertype. + justification: | + Specialization (generalization) relationships are core model structure. Drawing them + as open-arrowhead lines toward the supertype matches diagram notation and makes the + inheritance hierarchy visible. + tests: + - GeneralViewLayoutStrategy_BuildLayout_Subclassification_ProducesEdge + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-StdlibExclusion + title: >- + GeneralViewLayoutStrategy shall exclude standard-library definitions from the diagram. + justification: | + The General View is meant to show the user's model, not the imported standard library. + Excluding standard-library definitions keeps the diagram focused on user content. + tests: + - GeneralViewLayoutStrategy_BuildLayout_StdlibOnlyWorkspace_ReturnsMinimalCanvas + - GeneralViewLayoutStrategy_BuildLayout_SeedStdlibNames_AreExcluded + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-EmptyCanvas + title: >- + When the workspace contains no user-defined definitions, GeneralViewLayoutStrategy shall + return a minimal empty canvas with no nodes. + justification: | + A model with no user definitions has nothing to draw; returning a well-formed empty + canvas lets the renderer produce a valid blank diagram rather than failing. + tests: + - GeneralViewLayoutStrategy_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml new file mode 100644 index 00000000..b445c0a4 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml @@ -0,0 +1,32 @@ +--- +# GridViewLayoutStrategy Unit Requirements +# +# PURPOSE: +# - Define requirements for the GridViewLayoutStrategy unit +# - GridViewLayoutStrategy lays out a Grid View as a specialization relationship matrix +# - Requirements describe the observable matrix, not the arithmetic used + +sections: + - title: GridViewLayoutStrategy Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-GridViewLayoutStrategy-SpecializationMatrix + title: >- + GridViewLayoutStrategy shall present the user-defined definitions as a matrix with a + header row and header column of definition names and a mark in each cell where the row + definition specializes the column definition. + justification: | + A relationship matrix lets a reader see at a glance which definitions specialize which + others. Header labels on both axes and a mark at each related intersection are the + conventional way to render such a matrix. + tests: + - GridView_BuildLayout_Specialization_ProducesMarkedMatrix + + - id: SysML2Tools-Core-Layout-Internal-GridViewLayoutStrategy-EmptyInput + title: >- + When the workspace contains no user-defined definitions, GridViewLayoutStrategy shall + produce an empty diagram. + justification: | + With no definitions to relate, an empty diagram avoids presenting an empty matrix frame + that could be mistaken for missing data. + tests: + - BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml new file mode 100644 index 00000000..17d462bd --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml @@ -0,0 +1,42 @@ +--- +# InterconnectionViewLayoutStrategy Unit Requirements +# +# PURPOSE: +# - Define requirements for the InterconnectionViewLayoutStrategy unit +# - InterconnectionViewLayoutStrategy lays out the internals of one part definition +# - Requirements describe observable diagram content, not the placement algorithm + +sections: + - title: InterconnectionViewLayoutStrategy Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-Internals + title: >- + InterconnectionViewLayoutStrategy shall render the selected part definition as a + container box enclosing one box per nested part usage, a port on each part box for every + incident connection, and one connector line per connection. + justification: | + An Interconnection View exists to show the internal structure of a part definition β€” its + nested parts and how they are connected. The container box, part boxes, ports, and + connector lines are the observable content the view must provide. + tests: + - InterconnectionView_BuildLayout_PartsAndConnections_ProducesBoxesPortsAndLines + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-NonOverlapping + title: >- + InterconnectionViewLayoutStrategy shall position the nested part boxes so that they do + not overlap one another. + justification: | + Overlapping part boxes produce an unreadable internal-structure diagram. Non-overlap is + an observable property of the produced geometry the strategy must guarantee. + tests: + - InterconnectionView_BuildLayout_PartBoxes_DoNotOverlap + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-EmptyCanvas + title: >- + When the workspace contains no part definition with renderable internals, + InterconnectionViewLayoutStrategy shall return a minimal empty canvas with no nodes. + justification: | + With no eligible part definition there is nothing to draw; a well-formed empty canvas + lets the renderer produce a valid blank diagram rather than failing. + tests: + - InterconnectionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml new file mode 100644 index 00000000..976db4ea --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml @@ -0,0 +1,32 @@ +--- +# LayoutWarnings Unit Requirements +# +# PURPOSE: +# - Define requirements for the LayoutWarnings unit +# - LayoutWarnings builds the non-fatal layout-quality warning messages for a view +# - Requirements describe the observable warning output, not how it is formatted + +sections: + - title: LayoutWarnings Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-LayoutWarnings-NoCrossings + title: >- + When no connector had to cross a box, LayoutWarnings shall produce no warning. + justification: | + A clean diagram must not be flagged. Emitting no warning when there are no crossings + keeps the user's attention on real layout-quality problems. + tests: + - ForCrossings_Zero_ReturnsEmpty + + - id: SysML2Tools-Core-Layout-Internal-LayoutWarnings-CrossingWarning + title: >- + When one or more connectors had to cross a box, LayoutWarnings shall produce a warning + that names the view and reports the number of crossing connectors, with grammatical + agreement for singular and plural counts. + justification: | + Crossing connectors make a diagram harder to read, so the condition must be surfaced. + Naming the view and giving an accurate, grammatically correct count lets the user judge + the severity and find the affected diagram. + tests: + - ForCrossings_One_ReturnsSingularWarning + - ForCrossings_Many_ReturnsPluralWarning diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml new file mode 100644 index 00000000..ef6dcd42 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml @@ -0,0 +1,42 @@ +--- +# SequenceViewLayoutStrategy Unit Requirements +# +# PURPOSE: +# - Define requirements for the SequenceViewLayoutStrategy unit +# - SequenceViewLayoutStrategy lays out a Sequence View as lifelines and messages +# - Requirements describe observable layout properties, not the arithmetic used + +sections: + - title: SequenceViewLayoutStrategy Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-Lifelines + title: >- + SequenceViewLayoutStrategy shall render each participant referenced by the messages as a + lifeline and each message as a line between lifelines, ordered top-to-bottom by + declaration order. + justification: | + A sequence diagram communicates an interaction by showing who participates and in what + order they exchange messages. Presenting one lifeline per participant and ordering the + messages from top to bottom matches sequence-diagram reading conventions. + tests: + - SequenceView_BuildLayout_Messages_ProducesLifelinesAndOrderedLines + + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-HorizontalMessages + title: >- + SequenceViewLayoutStrategy shall draw a message between two lifelines as a horizontal line + from the sender to the receiver with a directional arrowhead at the receiver. + justification: | + A horizontal connector with an arrowhead pointing at the receiver shows the direction of + a message at a glance, which is the established notation for sequence messages. + tests: + - SequenceView_BuildLayout_Message_IsHorizontalBetweenLifelines + + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-EmptyInput + title: >- + When the workspace contains no messages, SequenceViewLayoutStrategy shall produce an empty + diagram. + justification: | + With nothing to depict, the strategy must not invent content; an empty diagram avoids + rendering a misleading frame with no participants or messages. + tests: + - SequenceView_BuildLayout_NoMessages_ReturnsMinimalCanvas diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml new file mode 100644 index 00000000..ae07abbc --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml @@ -0,0 +1,53 @@ +--- +# StateTransitionViewLayoutStrategy Unit Requirements +# +# PURPOSE: +# - Define requirements for the StateTransitionViewLayoutStrategy unit +# - StateTransitionViewLayoutStrategy lays out a State Transition View diagram +# - Requirements describe observable diagram content, not the placement algorithm + +sections: + - title: StateTransitionViewLayoutStrategy Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-States + title: >- + StateTransitionViewLayoutStrategy shall render a box for each state, an initial + pseudo-state marker, and connect transitions as arrows labelled with their guard + conditions. + justification: | + A State Transition View exists to show the states of a machine, where it starts, and how + it moves between states under guarded transitions. The state boxes, initial marker, and + guard-labelled arrows are the observable content the view must provide. + tests: + - StateTransitionView_BuildLayout_StatesAndTransitions_ProducesBoxesBadgeAndLines + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-ImplicitStates + title: >- + StateTransitionViewLayoutStrategy shall render a state box for any state referenced by a + transition even when that state is not separately declared. + justification: | + A transition can name a state that was never declared on its own. Showing it anyway keeps + the diagram complete so that no transition appears to lead nowhere. + tests: + - StateTransitionView_BuildLayout_UndeclaredStateInTransition_IsCreated + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-DistinctAnchors + title: >- + When a state edge carries both an outgoing and an incoming transition, + StateTransitionViewLayoutStrategy shall attach them at distinct anchor points. + justification: | + If an outgoing and an incoming transition shared one anchor, their arrows would coincide + and the direction of flow would be ambiguous. Distinct anchors keep each transition's + direction readable. + tests: + - StateTransitionView_BuildLayout_InAndOutOnSameEdge_UseDistinctAnchors + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-EmptyCanvas + title: >- + When the workspace contains no state definition, StateTransitionViewLayoutStrategy shall + return a minimal empty canvas with no nodes. + justification: | + With no state definition there is nothing to draw; a well-formed empty canvas lets the + renderer produce a valid blank diagram rather than failing. + tests: + - StateTransitionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas diff --git a/docs/reqstream/sysml2-tools-core/rendering/internal.yaml b/docs/reqstream/sysml2-tools-core/rendering/internal.yaml new file mode 100644 index 00000000..b10aaaf9 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/rendering/internal.yaml @@ -0,0 +1,35 @@ +--- +# Rendering Internal Subsystem Requirements +# +# PURPOSE: +# - Define requirements for the internal implementation of the Rendering subsystem +# - Internal components select the layout strategy for each view and exclude +# standard-library elements from user diagrams + +sections: + - title: Rendering Internal Subsystem Requirements + requirements: + - id: SysML2Tools-Core-Rendering-Internal-StrategySelection + title: >- + The internal rendering components shall select a view-specific layout strategy + for each view, based on the view's recognized kind, and shall fall back to the + general view strategy when the kind is not recognized. + justification: | + A single dispatch point keeps the renderer agnostic of how many view kinds exist + and lets new view kinds be added without changing the orchestration loop. Falling + back to the general view keeps unrecognized views renderable rather than failing. + tests: + - GetStrategy_InterconnectionNamedView_ReturnsInterconnectionStrategy + - GetStrategy_PlainView_ReturnsGeneralViewStrategy + + - id: SysML2Tools-Core-Rendering-Internal-StdlibExclusion + title: >- + The internal rendering components shall exclude standard-library elements from + user diagrams. + justification: | + The implicitly loaded OMG standard library would otherwise flood every diagram + with library definitions. Excluding stdlib elements keeps diagrams focused on the + user's model. + tests: + - GeneralViewLayoutStrategy_BuildLayout_SeedStdlibNames_AreExcluded + - GeneralViewLayoutStrategy_BuildLayout_StdlibOnlyWorkspace_ReturnsMinimalCanvas diff --git a/docs/reqstream/sysml2-tools-core/rendering/internal/diagram-type-router.yaml b/docs/reqstream/sysml2-tools-core/rendering/internal/diagram-type-router.yaml new file mode 100644 index 00000000..03bf22e6 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/rendering/internal/diagram-type-router.yaml @@ -0,0 +1,38 @@ +--- +# DiagramTypeRouter Unit Requirements +# +# PURPOSE: +# - Define requirements for the DiagramTypeRouter unit +# - DiagramTypeRouter selects the layout strategy for a given view +# - Requirements describe observable selection behavior, not the matching algorithm + +sections: + - title: DiagramTypeRouter Unit Requirements + requirements: + - id: SysML2Tools-Core-Rendering-Internal-DiagramTypeRouter-KindDispatch + title: >- + DiagramTypeRouter shall select the layout strategy that corresponds to a view's + kind, as identified from the view's name or its declared supertypes. + justification: | + Each view kind (interconnection, state transition, action flow, grid, browser, + sequence) requires a different layout. Selecting by the view's declared identity + lets an author choose a diagram type by naming or specializing the appropriate + viewpoint. + tests: + - GetStrategy_InterconnectionNamedView_ReturnsInterconnectionStrategy + - GetStrategy_ViewSpecializingInterconnection_ReturnsInterconnectionStrategy + - GetStrategy_StateTransitionNamedView_ReturnsStateStrategy + - GetStrategy_ActionFlowNamedView_ReturnsActionFlowStrategy + - GetStrategy_MatrixNamedView_ReturnsGridStrategy + - GetStrategy_BrowserNamedView_ReturnsBrowserStrategy + - GetStrategy_SequenceNamedView_ReturnsSequenceStrategy + + - id: SysML2Tools-Core-Rendering-Internal-DiagramTypeRouter-DefaultGeneral + title: >- + When a view matches no recognized view kind, DiagramTypeRouter shall select the + general view strategy. + justification: | + A sensible default keeps every view renderable. The general view is the most + broadly applicable layout and is the natural fallback for an unspecified view kind. + tests: + - GetStrategy_PlainView_ReturnsGeneralViewStrategy diff --git a/docs/verification/definition.yaml b/docs/verification/definition.yaml index 57530cf5..5b13c2cf 100644 --- a/docs/verification/definition.yaml +++ b/docs/verification/definition.yaml @@ -2,6 +2,13 @@ resource-path: - docs/verification - docs/verification/sysml2-tools-core + - docs/verification/sysml2-tools-core/semantic + - docs/verification/sysml2-tools-core/semantic/internal + - docs/verification/sysml2-tools-core/layout + - docs/verification/sysml2-tools-core/layout/engine + - docs/verification/sysml2-tools-core/layout/internal + - docs/verification/sysml2-tools-core/rendering + - docs/verification/sysml2-tools-core/rendering/internal - docs/verification/sysml2-tools-tool - docs/verification/sysml2-tools-tool/cli - docs/verification/sysml2-tools-tool/lint @@ -24,6 +31,34 @@ input-files: - docs/verification/sysml2-tools-tool/utilities/path-helpers.md - docs/verification/sysml2-tools-core.md - docs/verification/sysml2-tools-core/parser.md + - docs/verification/sysml2-tools-core/semantic.md + - docs/verification/sysml2-tools-core/semantic/workspace-loader.md + - docs/verification/sysml2-tools-core/semantic/internal.md + - docs/verification/sysml2-tools-core/semantic/internal/sysml-node.md + - docs/verification/sysml2-tools-core/semantic/internal/ast-builder.md + - docs/verification/sysml2-tools-core/semantic/internal/symbol-table.md + - docs/verification/sysml2-tools-core/semantic/internal/reference-resolver.md + - docs/verification/sysml2-tools-core/semantic/internal/supertype-walker.md + - docs/verification/sysml2-tools-core/layout.md + - docs/verification/sysml2-tools-core/layout/connector-label-placer.md + - docs/verification/sysml2-tools-core/layout/engine.md + - docs/verification/sysml2-tools-core/layout/engine/channel-router.md + - docs/verification/sysml2-tools-core/layout/engine/force-directed-engine.md + - docs/verification/sysml2-tools-core/layout/engine/port-assigner.md + - docs/verification/sysml2-tools-core/layout/engine/layered-layout-engine.md + - docs/verification/sysml2-tools-core/layout/engine/containment-packer.md + - docs/verification/sysml2-tools-core/layout/internal.md + - docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md + - docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md + - docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md + - docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md + - docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md + - docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md + - docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md + - docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md + - docs/verification/sysml2-tools-core/rendering.md + - docs/verification/sysml2-tools-core/rendering/internal.md + - docs/verification/sysml2-tools-core/rendering/internal/diagram-type-router.md - docs/verification/sysml2-tools-svg.md - docs/verification/sysml2-tools-png.md - docs/verification/ots.md diff --git a/docs/verification/sysml2-tools-core/layout/connector-label-placer.md b/docs/verification/sysml2-tools-core/layout/connector-label-placer.md new file mode 100644 index 00000000..ec867969 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/connector-label-placer.md @@ -0,0 +1,27 @@ +### ConnectorLabelPlacer Verification + +#### Verification Approach + +`ConnectorLabelPlacer` is verified through unit tests in `ConnectorLabelPlacerTests` that construct +explicit `LayoutLine` values, call `Place`, and assert on the returned position dictionary. The unit +is pure and deterministic, so no mocking is required. + +#### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +#### Acceptance Criteria + +- All `ConnectorLabelPlacerTests` pass with zero failures across all target frameworks. +- A line without a label is absent from the result. +- A single labelled line is placed at the midpoint of its longest segment. +- Two labels whose preferred positions coincide are separated so they do not overlap. + +#### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `Place_LineWithoutLabel_IsOmitted` | An unlabelled line is omitted from the result | +| `Place_SingleLine_UsesLongestSegmentMidpoint` | A label lands at the midpoint of the longest segment | +| `Place_CollidingLabels_AreSeparated` | Colliding labels are separated; the first keeps its preferred midpoint | diff --git a/docs/verification/sysml2-tools-core/layout/engine.md b/docs/verification/sysml2-tools-core/layout/engine.md new file mode 100644 index 00000000..7f1396d3 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/engine.md @@ -0,0 +1,36 @@ +### Layout Engine Subsystem Verification + +#### Verification Approach + +The Engine subsystem is verified through unit tests, one test class per engine, that supply +synthetic geometric input and assert on the returned geometry. No mocking is required: the +engines have no dependencies beyond the geometric value types, so tests construct inputs +directly and check observable properties of the output (orthogonality, non-overlap, bounds, +determinism, and layer ordering). + +#### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, +files, or configuration are required beyond a standard .NET SDK installation. + +#### Acceptance Criteria + +- All engine test classes pass with zero failures across all three target frameworks. +- Routed paths consist solely of axis-aligned segments. +- Placed boxes within a common region do not overlap. +- Placed geometry lies within the reported region bounds. +- Repeated invocations with identical input produce identical geometry. + +#### Test Scenarios + +| Scenario | Engine | Assertion | +| --- | --- | --- | +| Orthogonal path with no obstacles | `ChannelRouter` | Every segment is axis-aligned | +| Path around an obstacle | `ChannelRouter` | No segment crosses the obstacle interior | +| Clean route respects clearance | `ChannelRouter` | Segments stay the requested clearance from obstacles | +| Connected graph placement | `ForceDirectedEngine` | No overlaps; all rectangles within bounds | +| Deterministic placement | `ForceDirectedEngine` | Identical input yields identical output | +| Layered chain | `LayeredLayoutEngine` | Layers increase along the flow direction | +| Same-layer nodes | `LayeredLayoutEngine` | Nodes in one layer do not overlap | +| Port distribution | `PortAssigner` | Ports on one side are spread without coincidence | +| Container packing | `ContainmentPacker` | Packed boxes fit within the container | diff --git a/docs/verification/sysml2-tools-core/layout/engine/channel-router.md b/docs/verification/sysml2-tools-core/layout/engine/channel-router.md new file mode 100644 index 00000000..3bf6ffc5 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/engine/channel-router.md @@ -0,0 +1,39 @@ +#### ChannelRouter Verification + +##### Verification Approach + +`ChannelRouter` is verified through unit tests in `ChannelRouterTests` that construct +explicit source/target anchors and obstacle rectangles and assert on the returned path. +Geometric helpers in the test class confirm that every segment is axis-aligned, that no +segment passes through an obstacle interior, and that segments keep the requested clearance. +No mocking is required; the engine is pure and deterministic. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, +files, or configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `ChannelRouterTests` pass with zero failures across all three target frameworks. +- A route with no obstacles consists solely of axis-aligned segments. +- A route around an obstacle never enters the obstacle interior. +- A clean route keeps every segment at least the requested clearance from obstacles. +- A route with a given source or target side leaves or enters perpendicular to that side. +- A route that cannot avoid an obstacle reports `Crossed = true`; a clean route reports + `Crossed = false`. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `Route_NoObstacles_ProducesOrthogonalPath` | Path endpoints correct; all segments orthogonal | +| `Route_AlignedEndpoints_ProducesStraightLine` | Aligned anchors yield a single straight run | +| `Route_ObstacleBetween_RoutesAround` | Path avoids the obstacle interior | +| `Route_MultipleObstacles_RemainsValid` | Valid orthogonal path among several obstacles | +| `Route_WithSourceSide_LeavesPerpendicular` | First segment perpendicular to the source side | +| `Route_WithTargetSide_EntersPerpendicular` | Last segment perpendicular to the target side | +| `RouteWithStatus_NoBlockingObstacle_ReportsNotCrossed` | Clean route reports not crossed | +| `RouteWithStatus_ObstacleBetween_RoutesAroundWithoutCrossing` | Routed around; not crossed | +| `RouteWithStatus_CleanRoute_KeepsClearanceFromObstacles` | Segments respect clearance | +| `RouteWithStatus_TargetEnclosedByObstacle_ReportsCrossed` | Enclosed target reports crossed | diff --git a/docs/verification/sysml2-tools-core/layout/engine/containment-packer.md b/docs/verification/sysml2-tools-core/layout/engine/containment-packer.md new file mode 100644 index 00000000..e205ae25 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/engine/containment-packer.md @@ -0,0 +1,38 @@ +#### ContainmentPacker Verification + +##### Verification Approach + +`ContainmentPacker` is verified through unit tests in `ContainmentPackerTests` that construct +explicit item lists with known sizes and assert on the returned packing. Row sharing and wrapping +are checked by comparing the Y coordinates of placed rectangles; a geometric helper in the test +class checks whether two rectangles overlap, and bounds are checked against the reported region +size. The oversized-item case is exercised directly. No mocking is required; the packer is pure and +deterministic. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `ContainmentPackerTests` pass with zero failures across all three target frameworks. +- Items that fit within the content width share a single row, ordered left to right. +- An item that would exceed the content width wraps to a new row below. +- A mixed-size set produces no overlapping rectangles. +- Every packed rectangle lies within the reported region bounds. +- An item wider than the content width is placed alone and the region widens to contain it. +- An empty list yields an empty result sized only by the padding; a single item sits at the padding + origin. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `Pack_EmptyList_ReturnsPaddingOnlyRegion` | No rectangles; region is `2 * padding` on each axis | +| `Pack_SingleItem_PositionsAtPaddingOrigin` | Lone item at the padding origin; region wraps it plus padding | +| `Pack_ItemsFitInRow_ShareSameRow` | Items that fit share a row with increasing X positions | +| `Pack_ItemsExceedWidth_WrapToNewRow` | Overflowing item wraps to a new row at the left padding origin | +| `Pack_MixedSizes_ProducesNoOverlaps` | Every pair of packed rectangles is disjoint | +| `Pack_MixedSizes_AllRectsWithinBounds` | All rectangles lie within the reported region bounds | +| `Pack_ItemWiderThanContentWidth_PlacedAloneAndRegionWidens` | Oversized item placed alone; region grows to fit it | diff --git a/docs/verification/sysml2-tools-core/layout/engine/force-directed-engine.md b/docs/verification/sysml2-tools-core/layout/engine/force-directed-engine.md new file mode 100644 index 00000000..06f7ba7b --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/engine/force-directed-engine.md @@ -0,0 +1,33 @@ +#### ForceDirectedEngine Verification + +##### Verification Approach + +`ForceDirectedEngine` is verified through unit tests in `ForceDirectedEngineTests` that construct +explicit node and edge lists and assert on the returned placement. A geometric helper in the test +class checks whether two rectangles overlap, so the non-overlap property is verified directly on +the produced rectangles. No mocking is required; the engine is pure and deterministic, so the +determinism property is verified by placing identical input twice and comparing the results. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `ForceDirectedEngineTests` pass with zero failures across all three target frameworks. +- A connected graph is placed with no two node rectangles overlapping. +- Every placed rectangle lies within the reported region width and height. +- Identical inputs produce identical region size and rectangle positions. +- An empty node list yields an empty placement sized only by the padding. +- A single node is placed at the padding origin. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `Place_EmptyList_ReturnsPaddingOnlyRegion` | No rectangles; region is `2 * padding` on each axis | +| `Place_SingleNode_PositionsAtPadding` | Lone node placed at the padding origin | +| `Place_ConnectedGraph_ProducesNoOverlaps` | Every pair of placed rectangles is disjoint | +| `Place_ConnectedGraph_AllRectsWithinBounds` | All rectangles lie within the reported region bounds | +| `Place_SameInput_IsDeterministic` | Identical input yields identical region size and positions | diff --git a/docs/verification/sysml2-tools-core/layout/engine/layered-layout-engine.md b/docs/verification/sysml2-tools-core/layout/engine/layered-layout-engine.md new file mode 100644 index 00000000..f28e7960 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/engine/layered-layout-engine.md @@ -0,0 +1,34 @@ +#### LayeredLayoutEngine Verification + +##### Verification Approach + +`LayeredLayoutEngine` is verified through unit tests in `LayeredLayoutEngineTests` that construct +explicit node and directed-edge lists and assert on the returned placement. Layer assignment is +checked by comparing the reported `Layers` against the expected flow and by asserting that each +node's Y increases with its layer; a geometric helper in the test class checks whether two +rectangles overlap. A cyclic input is exercised to confirm the engine terminates and places every +node. No mocking is required; the engine is pure and deterministic. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `LayeredLayoutEngineTests` pass with zero failures across all three target frameworks. +- A chain assigns strictly increasing layers with increasing Y coordinates. +- Every edge runs from a strictly smaller layer to a larger layer. +- Nodes sharing a layer do not overlap. +- A cyclic graph terminates and places every node within the region bounds. +- An empty node list yields an empty placement sized only by the padding. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `Place_EmptyList_ReturnsPaddingOnlyRegion` | No rectangles; region is `2 * padding` on each axis | +| `Place_Chain_AssignsIncreasingLayers` | Chain layers are 0,1,2,3 and Y increases with layer | +| `Place_Branching_EdgesPointDownward` | Every edge source sits in a strictly smaller layer than its target | +| `Place_SameLayerNodes_DoNotOverlap` | Nodes sharing a layer are pairwise disjoint | +| `Place_Cycle_TerminatesAndPlacesAllNodes` | Cyclic input places all nodes within the region bounds | diff --git a/docs/verification/sysml2-tools-core/layout/engine/port-assigner.md b/docs/verification/sysml2-tools-core/layout/engine/port-assigner.md new file mode 100644 index 00000000..4fd17149 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/engine/port-assigner.md @@ -0,0 +1,31 @@ +#### PortAssigner Verification + +##### Verification Approach + +`PortAssigner` is verified through unit tests in `PortAssignerTests` that construct explicit box +rectangles and port requests and assert on the returned placements. Side selection is checked with +a parameterized theory covering all four directions; boundary placement and even distribution are +checked by asserting exact coordinates against the box geometry. No mocking is required; the +assigner is pure and deterministic. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `PortAssignerTests` pass with zero failures across all three target frameworks. +- A single port is assigned to the box side facing its target for each of the four directions. +- A placed port's centre lies on the boundary of its assigned side. +- Multiple ports on the same side occupy distinct, evenly spaced positions. +- An empty request list yields no placements. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `Assign_SinglePort_ChoosesSideFacingTarget` | Port lands on the side facing its target (all four directions) | +| `Assign_Port_CentreLiesOnBoxBoundary` | Port centre sits on the assigned side's edge, within the box extent | +| `Assign_MultiplePortsSameSide_AreEvenlyDistributed` | Same-side ports occupy distinct, evenly spaced slots | +| `Assign_Empty_ReturnsEmpty` | An empty request list yields no placements | diff --git a/docs/verification/sysml2-tools-core/layout/internal.md b/docs/verification/sysml2-tools-core/layout/internal.md new file mode 100644 index 00000000..c3c22600 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal.md @@ -0,0 +1,34 @@ +### Layout Internal Subsystem Verification + +#### Verification Approach + +The Internal subsystem is verified through unit tests, one test class per view layout strategy, +that construct a synthetic `SysmlWorkspace`, invoke `BuildLayout`, and assert on the returned +`LayoutTree`. The tests inspect the node tree for the expected boxes, ports, lines, badges, and +canvas dimensions. No mocking is required: the strategies depend only on the in-memory semantic +model, the geometric engines, and the theme, all of which the tests construct directly. + +#### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +#### Acceptance Criteria + +- All view layout strategy test classes pass with zero failures across all three target frameworks. +- Each strategy produces a layout tree whose nodes match the elements of its synthetic input. +- A workspace with no relevant elements yields a minimal empty canvas with no nodes. +- Standard-library elements are absent from the produced diagrams. +- Boxes within a diagram do not overlap one another. + +#### Test Scenarios + +| Scenario | Strategy | Assertion | +| --- | --- | --- | +| Definitions rendered with keywords | `GeneralViewLayoutStrategy` | Each definition becomes a keyword-carrying box | +| Standard-library elements excluded | `GeneralViewLayoutStrategy` | Stdlib-only input yields a minimal empty canvas | +| Parts, ports, and connectors | `InterconnectionViewLayoutStrategy` | Container box, parts, ports, connection lines | +| Non-overlapping part boxes | `InterconnectionViewLayoutStrategy` | No two part boxes overlap | +| States, marker, and transitions | `StateTransitionViewLayoutStrategy` | State boxes, initial badge, guard lines | +| Actions, markers, and flows | `ActionFlowViewLayoutStrategy` | Action boxes, start/done markers, and flow lines | +| Empty workspace | All strategies | A minimal empty canvas with no nodes | diff --git a/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md new file mode 100644 index 00000000..d0da49e9 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md @@ -0,0 +1,31 @@ +#### ActionFlowViewLayoutStrategy Verification + +##### Verification Approach + +`ActionFlowViewLayoutStrategy` is verified through unit tests in +`ActionFlowViewLayoutStrategyTests` that construct a synthetic `SysmlWorkspace` containing an +action definition with actions and successions, invoke `BuildLayout`, and assert on the returned +`LayoutTree`. Assertions count the action boxes, confirm the start (filled-circle) and done +(bullseye) markers and the flow lines, and compare action box `Y` coordinates to confirm +top-to-bottom ordering. No mocking is required; the strategy depends only on the in-memory model, +the geometric engines, and the theme. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `ActionFlowViewLayoutStrategyTests` pass with zero failures across all three target frameworks. +- An action definition yields one box per action, a start marker, a done marker, and flow lines. +- A succession's target action is positioned below its source action. +- An empty workspace yields a canvas with no nodes. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `ActionFlowView_BuildLayout_ActionsAndSuccessions_ProducesBoxesMarkersAndFlows` | Action boxes, markers, and flows | +| `ActionFlowView_BuildLayout_Successions_FlowTopToBottom` | The source action sits above its successor | +| `ActionFlowView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas` | Canvas with no nodes | diff --git a/docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md new file mode 100644 index 00000000..ccfb1d3f --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md @@ -0,0 +1,27 @@ +#### BrowserViewLayoutStrategy Verification + +##### Verification Approach + +`BrowserViewLayoutStrategy` is verified through unit tests in `BrowserAndGridViewLayoutStrategyTests` +that build a `SysmlWorkspace` with a nested membership hierarchy, run `BuildLayout`, and assert on +the returned `LayoutTree`. The strategy is pure and deterministic, so no mocking is required; real +workspace and rendering-option values are constructed directly. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- The browser-view tests in `BrowserAndGridViewLayoutStrategyTests` pass with zero failures across + all target frameworks. +- A nested element's box is indented further than its ancestor's box. +- A workspace with no user-defined elements yields an empty diagram. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `BrowserView_BuildLayout_NestedElements_AreIndentedByDepth` | Nested element box has larger X than its ancestor box | +| `BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas` | Empty workspace yields no nodes | diff --git a/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md new file mode 100644 index 00000000..e9f6b7fc --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md @@ -0,0 +1,38 @@ +#### GeneralViewLayoutStrategy Verification + +##### Verification Approach + +`GeneralViewLayoutStrategy` is verified through unit tests in `GeneralViewLayoutStrategyTests` +that construct a synthetic `SysmlWorkspace` of definitions, invoke `BuildLayout`, and assert on +the returned `LayoutTree`. A recursive helper collects boxes from the (possibly nested) node tree +so assertions can confirm box keywords, folder shapes, compartments, and specialization lines. No +mocking is required; the strategy depends only on the in-memory model, the geometric engines, and +the theme, all constructed directly by the tests. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `GeneralViewLayoutStrategyTests` pass with zero failures across all three target frameworks. +- Each user definition appears as a box carrying its definition keyword. +- A package's definitions appear inside a folder-shaped box labelled with the package name. +- A definition's owned usages appear as compartment rows formatted `name : Type`. +- A specialization yields a line with an open arrowhead at the supertype end. +- Standard-library-only input (by prefix or by seed set) yields a minimal empty canvas. +- An empty workspace yields a 200Γ—100 canvas with no nodes. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `GeneralViewLayoutStrategy_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas` | 200Γ—100 canvas with no nodes | +| `GeneralViewLayoutStrategy_BuildLayout_StdlibOnlyWorkspace_ReturnsMinimalCanvas` | Stdlib defs excluded; no nodes | +| `GeneralViewLayoutStrategy_BuildLayout_OneUserPartDef_ProducesLayoutBox` | A user part def produces at least one box | +| `GeneralViewLayoutStrategy_BuildLayout_MixedDefinitionKinds_RendersAllWithKeywords` | Each def carries its keyword | +| `GeneralViewLayoutStrategy_BuildLayout_PackagedDefinitions_ProducesFolderBox` | Folder box with package keyword | +| `GeneralViewLayoutStrategy_BuildLayout_Subclassification_ProducesEdge` | Line with open arrowhead at supertype | +| `GeneralViewLayoutStrategy_BuildLayout_SeedStdlibNames_AreExcluded` | Seed-listed definitions excluded; empty canvas | +| `GeneralViewLayoutStrategy_BuildLayout_DefinitionWithUsages_ProducesCompartments` | Attribute and port compartments | diff --git a/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md new file mode 100644 index 00000000..4bafbfb6 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md @@ -0,0 +1,28 @@ +#### GridViewLayoutStrategy Verification + +##### Verification Approach + +`GridViewLayoutStrategy` is verified through unit tests in `BrowserAndGridViewLayoutStrategyTests` +that build a `SysmlWorkspace` of definitions with specialization relationships, run `BuildLayout`, +and assert on the returned `LayoutTree`. The strategy is pure and deterministic, so no mocking is +required; real workspace and rendering-option values are constructed directly. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- The grid-view tests in `BrowserAndGridViewLayoutStrategyTests` pass with zero failures across all + target frameworks. +- Definitions with a specialization relationship yield a grid with a header row and exactly one mark + at the specializing intersection. +- A workspace with no user-defined definitions yields an empty diagram. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `GridView_BuildLayout_Specialization_ProducesMarkedMatrix` | Grid has a header row and one specialization mark | +| `BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas` | Empty workspace yields no nodes | diff --git a/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md new file mode 100644 index 00000000..9ebe2aca --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md @@ -0,0 +1,31 @@ +#### InterconnectionViewLayoutStrategy Verification + +##### Verification Approach + +`InterconnectionViewLayoutStrategy` is verified through unit tests in +`InterconnectionViewLayoutStrategyTests` that construct a synthetic `SysmlWorkspace` containing a +part definition with nested parts and connections, invoke `BuildLayout`, and assert on the +returned `LayoutTree`. Assertions count the container box, rounded part boxes, port nodes, and +connector lines, and a geometric helper confirms that no two part boxes overlap. No mocking is +required; the strategy depends only on the in-memory model, the geometric engines, and the theme. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `InterconnectionViewLayoutStrategyTests` pass with zero failures across all three target frameworks. +- A part definition with nested parts and connections yields a container box, one rounded box per + part, one port per connection endpoint, and one connector line per connection. +- No two part boxes overlap. +- An empty workspace yields a canvas with no nodes. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `InterconnectionView_BuildLayout_PartsAndConnections_ProducesBoxesPortsAndLines` | Box, parts, ports, and lines | +| `InterconnectionView_BuildLayout_PartBoxes_DoNotOverlap` | No two rounded part boxes overlap | +| `InterconnectionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas` | Canvas with no nodes | diff --git a/docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md b/docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md new file mode 100644 index 00000000..a4cee701 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md @@ -0,0 +1,27 @@ +#### LayoutWarnings Verification + +##### Verification Approach + +`LayoutWarnings` is verified through unit tests in `LayoutWarningsTests` that call `ForCrossings` +with a view name and a crossing count and assert on the returned list. The unit is a pure function, +so no mocking is required. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `LayoutWarningsTests` pass with zero failures across all target frameworks. +- A zero crossing count yields no warning. +- A count of one yields a single singular-form warning naming the view. +- A count greater than one yields a single plural-form warning reporting the count. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `ForCrossings_Zero_ReturnsEmpty` | Zero crossings yields an empty list | +| `ForCrossings_One_ReturnsSingularWarning` | One crossing yields a singular warning naming the view | +| `ForCrossings_Many_ReturnsPluralWarning` | Multiple crossings yield a plural warning with the count | diff --git a/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md new file mode 100644 index 00000000..0a1218df --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md @@ -0,0 +1,29 @@ +#### SequenceViewLayoutStrategy Verification + +##### Verification Approach + +`SequenceViewLayoutStrategy` is verified through unit tests in `SequenceViewLayoutStrategyTests` +that build a `SysmlWorkspace` containing a definition with message connections, run `BuildLayout`, +and assert on the returned `LayoutTree`. The strategy is pure and deterministic, so no mocking is +required; real workspace and rendering-option values are constructed directly. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `SequenceViewLayoutStrategyTests` pass with zero failures across all target frameworks. +- A definition with messages yields one lifeline per participant and one line per message, ordered + top-to-bottom by declaration order. +- A message between two lifelines is a horizontal line with a directional arrowhead at the receiver. +- A workspace with no messages yields an empty diagram. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `SequenceView_BuildLayout_Messages_ProducesLifelinesAndOrderedLines` | Lifeline per participant; ordered top-down | +| `SequenceView_BuildLayout_Message_IsHorizontalBetweenLifelines` | Horizontal line, filled arrowhead at receiver | +| `SequenceView_BuildLayout_NoMessages_ReturnsMinimalCanvas` | Workspace with no messages yields no nodes | diff --git a/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md new file mode 100644 index 00000000..cdd7d121 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md @@ -0,0 +1,34 @@ +#### StateTransitionViewLayoutStrategy Verification + +##### Verification Approach + +`StateTransitionViewLayoutStrategy` is verified through unit tests in +`StateTransitionViewLayoutStrategyTests` that construct a synthetic `SysmlWorkspace` containing a +state definition with states and transitions, invoke `BuildLayout`, and assert on the returned +`LayoutTree`. Assertions count the state boxes, confirm the initial-state badge, check guard +labels on the transition lines, and compare transition endpoint waypoints to confirm distinct +anchors. No mocking is required; the strategy depends only on the in-memory model, the geometric +engines, and the theme. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `StateTransitionViewLayoutStrategyTests` pass with zero failures across all three target frameworks. +- A state definition yields one state box per state, an initial-state badge, and guard-labelled + transition lines. +- A state named only by a transition is still rendered as a box. +- An outgoing and an incoming transition on the same edge use distinct anchor points. +- An empty workspace yields a canvas with no nodes. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `StateTransitionView_BuildLayout_StatesAndTransitions_ProducesBoxesBadgeAndLines` | State boxes, badge, guard line | +| `StateTransitionView_BuildLayout_UndeclaredStateInTransition_IsCreated` | Transition-only target rendered as a box | +| `StateTransitionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas` | Canvas with no nodes | +| `StateTransitionView_BuildLayout_InAndOutOnSameEdge_UseDistinctAnchors` | In/out transitions use distinct anchors | diff --git a/docs/verification/sysml2-tools-core/rendering/internal.md b/docs/verification/sysml2-tools-core/rendering/internal.md new file mode 100644 index 00000000..9afbc02b --- /dev/null +++ b/docs/verification/sysml2-tools-core/rendering/internal.md @@ -0,0 +1,30 @@ +### Rendering Internal Subsystem Verification + +#### Verification Approach + +The internal rendering components are verified through unit tests. `DiagramTypeRouter` is +covered by `DiagramTypeRouterTests`, which construct view nodes of each kind and assert on the +concrete strategy returned. The `StdlibFilter` behavior is verified indirectly through the +view-strategy tests that assert standard-library elements are excluded from the produced +layout. No mocking is required; both components are pure and deterministic. + +#### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, +or configuration are required beyond a standard .NET SDK installation. + +#### Acceptance Criteria + +- All `DiagramTypeRouterTests` pass with zero failures across all three target frameworks. +- Each recognized view kind routes to its corresponding strategy. +- A view matching no recognized kind routes to the general view strategy. +- Standard-library elements do not appear in the produced layout. + +#### Test Scenarios + +| Scenario | Assertion | +| --- | --- | +| Interconnection-named or specializing view | Routes to the interconnection strategy | +| State transition / action flow / matrix / browser / sequence views | Route to their strategies | +| Plain view | Routes to the general view strategy | +| Standard-library-only workspace | Produces a minimal canvas (stdlib excluded) | diff --git a/docs/verification/sysml2-tools-core/rendering/internal/diagram-type-router.md b/docs/verification/sysml2-tools-core/rendering/internal/diagram-type-router.md new file mode 100644 index 00000000..397f4129 --- /dev/null +++ b/docs/verification/sysml2-tools-core/rendering/internal/diagram-type-router.md @@ -0,0 +1,32 @@ +#### DiagramTypeRouter Verification + +##### Verification Approach + +`DiagramTypeRouter` is verified through unit tests in `DiagramTypeRouterTests` that construct a +view node of each kind (by name and by specialization) and assert that `GetStrategy` returns the +expected concrete strategy type. No mocking is required; the router is pure and deterministic. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `DiagramTypeRouterTests` pass with zero failures across all three target frameworks. +- Each recognized view kind, whether identified by name or by a specialized supertype, routes to + its corresponding strategy. +- A view matching no recognized kind routes to the general view strategy. + +##### Test Scenarios + +| Test | Assertion | +| --- | --- | +| `GetStrategy_InterconnectionNamedView_ReturnsInterconnectionStrategy` | Interconnection by name | +| `GetStrategy_ViewSpecializingInterconnection_ReturnsInterconnectionStrategy` | Interconnection by supertype | +| `GetStrategy_StateTransitionNamedView_ReturnsStateStrategy` | State transition view | +| `GetStrategy_ActionFlowNamedView_ReturnsActionFlowStrategy` | Action flow view | +| `GetStrategy_MatrixNamedView_ReturnsGridStrategy` | Grid/matrix view | +| `GetStrategy_BrowserNamedView_ReturnsBrowserStrategy` | Browser/tree view | +| `GetStrategy_SequenceNamedView_ReturnsSequenceStrategy` | Sequence view | +| `GetStrategy_PlainView_ReturnsGeneralViewStrategy` | Unrecognized view falls back to general | diff --git a/requirements.yaml b/requirements.yaml index 4306cfc8..00f7994a 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -14,7 +14,25 @@ includes: - docs/reqstream/sysml2-tools-core/semantic/internal/reference-resolver.yaml - docs/reqstream/sysml2-tools-core/semantic/internal/supertype-walker.yaml - docs/reqstream/sysml2-tools-core/layout.yaml + - docs/reqstream/sysml2-tools-core/layout/connector-label-placer.yaml + - docs/reqstream/sysml2-tools-core/layout/engine.yaml + - docs/reqstream/sysml2-tools-core/layout/engine/channel-router.yaml + - docs/reqstream/sysml2-tools-core/layout/engine/force-directed-engine.yaml + - docs/reqstream/sysml2-tools-core/layout/engine/port-assigner.yaml + - docs/reqstream/sysml2-tools-core/layout/engine/layered-layout-engine.yaml + - docs/reqstream/sysml2-tools-core/layout/engine/containment-packer.yaml + - docs/reqstream/sysml2-tools-core/layout/internal.yaml + - docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml + - docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml + - docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml + - docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml + - docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml + - docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml + - docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml + - docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml - docs/reqstream/sysml2-tools-core/rendering.yaml + - docs/reqstream/sysml2-tools-core/rendering/internal.yaml + - docs/reqstream/sysml2-tools-core/rendering/internal/diagram-type-router.yaml - docs/reqstream/sysml2-tools-svg.yaml - docs/reqstream/sysml2-tools-png.yaml - docs/reqstream/sysml2-tools-tool.yaml diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/BoxMetrics.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/BoxMetrics.cs new file mode 100644 index 00000000..84070b86 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/BoxMetrics.cs @@ -0,0 +1,53 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Rendering; + +namespace DemaConsulting.SysML2Tools.Layout; + +/// +/// 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.SysML2Tools.Core/Layout/ConnectorLabelPlacer.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/ConnectorLabelPlacer.cs new file mode 100644 index 00000000..26a8c6d5 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/ConnectorLabelPlacer.cs @@ -0,0 +1,171 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.SysML2Tools.Layout; + +/// +/// 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 Rect(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)); + } + } + + // Give up: fall back to the longest segment's midpoint. + return (best.X, best.Y); + } + + /// 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 rectangle used for label overlap tests. + /// Left edge. + /// Top edge. + /// Right edge. + /// Bottom edge. + private readonly record struct Rect(double Left, double Top, double Right, double Bottom); +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs new file mode 100644 index 00000000..99fb539b --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs @@ -0,0 +1,543 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.SysML2Tools.Layout.Engine; + +/// +/// An axis-aligned rectangle obstacle used by . +/// +/// Absolute X coordinate of the left edge in logical pixels. +/// Absolute Y coordinate of the top edge in logical pixels. +/// Width in logical pixels. +/// Height in logical pixels. +internal readonly record struct Rect(double X, double Y, double Width, double Height); + +/// +/// 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); + +/// +/// 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 ChannelRouter +{ + /// 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 . + /// + /// 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) => + RouteWithStatus(source, target, obstacles, clearance, sourceSide, targetSide).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). + /// 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) + { + 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). + 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); + + 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). + /// + 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) + { + 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 turnCost = cd != Dir.None && cd != nd ? TurnPenalty : 0.0; + var tentative = g + stepLength + 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]); + + /// + /// 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.SysML2Tools.Core/Layout/Engine/ContainmentPacker.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ContainmentPacker.cs new file mode 100644 index 00000000..235f3e70 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ContainmentPacker.cs @@ -0,0 +1,116 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.SysML2Tools.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.SysML2Tools.Core/Layout/Engine/ForceDirectedEngine.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ForceDirectedEngine.cs new file mode 100644 index 00000000..213fe6fc --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ForceDirectedEngine.cs @@ -0,0 +1,250 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.SysML2Tools.Layout.Engine; + +/// +/// A node to be placed by the , identified by its size. Callers +/// map results back to model elements by index. +/// +/// Width of the node's bounding box in logical pixels. +/// Height of the node's bounding box in logical pixels. +internal readonly record struct ForceNode(double Width, double Height); + +/// +/// An undirected edge between two nodes (by index) that contributes an attractive spring force. +/// +/// Index of the first node. +/// Index of the second node. +internal readonly record struct ForceEdge(int A, int B); + +/// +/// The result of a force-directed placement. +/// +/// Total width of the placed region (including padding) in logical pixels. +/// Total height of the placed region (including padding) in logical pixels. +/// Placed rectangles, one per input node in the same order. +internal sealed record ForceResult(double Width, double Height, IReadOnlyList Rects); + +/// +/// A deterministic Fruchterman-Reingold force-directed layout engine. Nodes repel one another and +/// edges act as springs; after a fixed number of cooling iterations the node centres settle into a +/// spread-out arrangement. A final overlap-removal pass guarantees non-overlapping bounding boxes. +/// +/// +/// The initial placement is seeded deterministically (a golden-angle spiral) so that results are +/// reproducible across runs and platforms. The engine returns absolute rectangles translated so the +/// region origin is (0, 0) plus a uniform padding margin. +/// +internal static class ForceDirectedEngine +{ + /// Number of force-application iterations. + private const int Iterations = 300; + + /// Golden angle in radians, used to spread the deterministic initial seed. + private const double GoldenAngle = 2.399963229728653; + + /// + /// Computes a force-directed placement for the given nodes and edges. + /// + /// Nodes to place, in caller order. + /// Edges contributing attractive forces (indices into ). + /// Nominal spacing between adjacent node centres (the spring rest length). + /// Uniform padding added around the placed region. + /// A with one rectangle per node and the region size. + public static ForceResult Place( + IReadOnlyList nodes, + IReadOnlyList edges, + double spacing, + double padding) + { + ArgumentNullException.ThrowIfNull(nodes); + ArgumentNullException.ThrowIfNull(edges); + + var n = nodes.Count; + if (n == 0) + { + return new ForceResult(2.0 * padding, 2.0 * padding, []); + } + + if (n == 1) + { + var only = new[] { new PackedRect(padding, padding, nodes[0].Width, nodes[0].Height) }; + return new ForceResult(nodes[0].Width + (2.0 * padding), nodes[0].Height + (2.0 * padding), only); + } + + // Deterministic spiral seed positions centred on the origin. + var px = new double[n]; + var py = new double[n]; + for (var i = 0; i < n; i++) + { + var radius = spacing * Math.Sqrt(i + 1); + var angle = i * GoldenAngle; + px[i] = radius * Math.Cos(angle); + py[i] = radius * Math.Sin(angle); + } + + // Optimal distance between nodes (Fruchterman-Reingold "k"). + var k = spacing; + var area = k * k * n; + var temperature = Math.Sqrt(area) / 2.0; + var cooling = temperature / (Iterations + 1); + + ApplyForces(nodes, edges, px, py, k, temperature, cooling); + RemoveOverlaps(nodes, px, py, spacing); + + return BuildResult(nodes, px, py, padding); + } + + /// Runs the iterative repulsion/attraction force simulation in place. + private static void ApplyForces( + IReadOnlyList nodes, + IReadOnlyList edges, + double[] px, + double[] py, + double k, + double temperature, + double cooling) + { + var n = nodes.Count; + var dx = new double[n]; + var dy = new double[n]; + + for (var iter = 0; iter < Iterations; iter++) + { + Array.Clear(dx); + Array.Clear(dy); + + // Repulsive forces between every pair of nodes. + for (var i = 0; i < n; i++) + { + for (var j = i + 1; j < n; j++) + { + var deltaX = px[i] - px[j]; + var deltaY = py[i] - py[j]; + var dist = Math.Max(Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)), 0.01); + var repulse = (k * k) / dist; + var ux = deltaX / dist; + var uy = deltaY / dist; + dx[i] += ux * repulse; + dy[i] += uy * repulse; + dx[j] -= ux * repulse; + dy[j] -= uy * repulse; + } + } + + // Attractive forces along edges. + foreach (var edge in edges) + { + var deltaX = px[edge.A] - px[edge.B]; + var deltaY = py[edge.A] - py[edge.B]; + var dist = Math.Max(Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY)), 0.01); + var attract = (dist * dist) / k; + var ux = deltaX / dist; + var uy = deltaY / dist; + dx[edge.A] -= ux * attract; + dy[edge.A] -= uy * attract; + dx[edge.B] += ux * attract; + dy[edge.B] += uy * attract; + } + + // Displace each node, capped by the current temperature, then cool down. + for (var i = 0; i < n; i++) + { + var disp = Math.Max(Math.Sqrt((dx[i] * dx[i]) + (dy[i] * dy[i])), 0.01); + var capped = Math.Min(disp, temperature); + px[i] += (dx[i] / disp) * capped; + py[i] += (dy[i] / disp) * capped; + } + + temperature = Math.Max(temperature - cooling, 0.0); + } + } + + /// + /// Iteratively pushes apart any overlapping node bounding boxes (with a small gap) until no + /// overlaps remain or an iteration cap is reached. + /// + private static void RemoveOverlaps(IReadOnlyList nodes, double[] px, double[] py, double gap) + { + var n = nodes.Count; + const int MaxPasses = 200; + var margin = gap * 0.3; + + for (var pass = 0; pass < MaxPasses; pass++) + { + var moved = false; + for (var i = 0; i < n; i++) + { + for (var j = i + 1; j < n; j++) + { + var halfW = ((nodes[i].Width + nodes[j].Width) / 2.0) + margin; + var halfH = ((nodes[i].Height + nodes[j].Height) / 2.0) + margin; + var deltaX = px[j] - px[i]; + var deltaY = py[j] - py[i]; + var overlapX = halfW - Math.Abs(deltaX); + var overlapY = halfH - Math.Abs(deltaY); + + if (overlapX <= 0 || overlapY <= 0) + { + continue; + } + + // Resolve along the axis of least penetration. + if (overlapX < overlapY) + { + var shift = (overlapX / 2.0) * (deltaX < 0 ? -1.0 : 1.0); + px[i] -= shift; + px[j] += shift; + } + else + { + var shift = (overlapY / 2.0) * (deltaY < 0 ? -1.0 : 1.0); + py[i] -= shift; + py[j] += shift; + } + + moved = true; + } + } + + if (!moved) + { + break; + } + } + } + + /// Translates centre positions to top-left rectangles and computes the region size. + private static ForceResult BuildResult(IReadOnlyList nodes, double[] px, double[] py, double padding) + { + var n = nodes.Count; + var minX = double.MaxValue; + var minY = double.MaxValue; + var maxX = double.MinValue; + var maxY = double.MinValue; + + for (var i = 0; i < n; i++) + { + var left = px[i] - (nodes[i].Width / 2.0); + var top = py[i] - (nodes[i].Height / 2.0); + minX = Math.Min(minX, left); + minY = Math.Min(minY, top); + maxX = Math.Max(maxX, left + nodes[i].Width); + maxY = Math.Max(maxY, top + nodes[i].Height); + } + + var rects = new PackedRect[n]; + for (var i = 0; i < n; i++) + { + var left = px[i] - (nodes[i].Width / 2.0) - minX + padding; + var top = py[i] - (nodes[i].Height / 2.0) - minY + padding; + rects[i] = new PackedRect(left, top, nodes[i].Width, nodes[i].Height); + } + + var width = (maxX - minX) + (2.0 * padding); + var height = (maxY - minY) + (2.0 * padding); + return new ForceResult(width, height, rects); + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs new file mode 100644 index 00000000..3a0a5fd4 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs @@ -0,0 +1,452 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.SysML2Tools.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 LayeredNode(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 LayeredEdge(int From, int To); + +/// +/// The result of a layered placement. +/// +/// Total width of the placed region (including padding) in logical pixels. +/// Total height of the placed region (including padding) in logical pixels. +/// Placed rectangles, one per input node in the same order. +/// The assigned layer index of each node, in node order. +internal sealed record LayeredResult(double Width, double Height, IReadOnlyList Rects, IReadOnlyList Layers); + +/// +/// A simplified Sugiyama-style layered layout engine for directed graphs. Produces a top-to-bottom +/// flow: cycles are broken, nodes are assigned to layers by longest path from the sources, ordered +/// within layers to reduce edge crossings (Barycenter heuristic), and given coordinates. +/// +/// +/// The engine is deterministic. It guarantees that every edge points from a lower layer (smaller Y) +/// to a higher layer for non-reversed edges, and that no two nodes in the same layer overlap. +/// +internal static class LayeredLayoutEngine +{ + /// Number of Barycenter ordering sweeps (down + up counts as two). + private const int OrderingSweeps = 8; + + /// + /// Computes a layered placement for the given nodes and directed edges. + /// + /// Nodes to place, in caller order. + /// Directed edges (indices into ). + /// Vertical gap between adjacent layers. + /// Horizontal gap between adjacent nodes in a layer. + /// Uniform padding added around the placed region. + /// A with one rectangle per node and the region size. + public static LayeredResult Place( + IReadOnlyList nodes, + IReadOnlyList edges, + double layerGap, + double nodeGap, + double padding) + { + ArgumentNullException.ThrowIfNull(nodes); + ArgumentNullException.ThrowIfNull(edges); + + var n = nodes.Count; + if (n == 0) + { + return new LayeredResult(2.0 * padding, 2.0 * padding, [], []); + } + + // Break cycles so layering terminates, then assign layers by longest path. + var acyclic = BreakCycles(n, edges); + var layers = AssignLayers(n, acyclic); + + // Group nodes by layer and order within each layer to reduce crossings. + var layerGroups = GroupByLayer(layers); + OrderLayers(layerGroups, acyclic); + + return AssignCoordinates(nodes, layerGroups, layers, acyclic, layerGap, nodeGap, padding); + } + + /// + /// Returns the edge set with cycle-causing back edges reversed, using a DFS that classifies an + /// edge to a node currently 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.From != e.To) + { + adjacency[e.From].Add(e.To); + } + } + + 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); + } + } + + // Rebuild the edge list with back edges reversed and self/duplicate edges dropped. + var result = new List(); + var seen = new HashSet<(int, int)>(); + foreach (var e in edges) + { + if (e.From == e.To) + { + continue; + } + + var (from, to) = backEdges.Contains((e.From, e.To)) ? (e.To, e.From) : (e.From, e.To); + if (from != to && seen.Add((from, to))) + { + result.Add(new LayeredEdge(from, to)); + } + } + + return result; + } + + /// Assigns each node to a layer equal to its longest path from any source. + private static int[] AssignLayers(int n, List edges) + { + var incoming = new List[n]; + var outgoing = new List[n]; + var inDegree = new int[n]; + for (var i = 0; i < n; i++) + { + incoming[i] = []; + outgoing[i] = []; + } + + foreach (var e in edges) + { + outgoing[e.From].Add(e.To); + incoming[e.To].Add(e.From); + inDegree[e.To]++; + } + + // Topological order (the edge set is acyclic after BreakCycles). + 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; + } + + /// Groups node indices by their assigned layer, ordered by layer then index. + private static List> GroupByLayer(int[] layers) + { + var maxLayer = layers.Length == 0 ? 0 : layers.Max(); + var groups = new List>(); + for (var l = 0; l <= maxLayer; l++) + { + groups.Add([]); + } + + for (var i = 0; i < layers.Length; i++) + { + groups[layers[i]].Add(i); + } + + return groups; + } + + /// + /// Reorders nodes within each layer using repeated Barycenter sweeps over the adjacent layers + /// to reduce edge crossings. + /// + private static void OrderLayers(List> layerGroups, List edges) + { + var n = layerGroups.Sum(g => g.Count); + var neighborsUp = new List[n]; + var neighborsDown = new List[n]; + for (var i = 0; i < n; i++) + { + neighborsUp[i] = []; + neighborsDown[i] = []; + } + + foreach (var e in edges) + { + neighborsDown[e.From].Add(e.To); + neighborsUp[e.To].Add(e.From); + } + + for (var sweep = 0; sweep < OrderingSweeps; sweep++) + { + var downward = sweep % 2 == 0; + if (downward) + { + for (var l = 1; l < layerGroups.Count; l++) + { + SortByBarycenter(layerGroups[l], layerGroups[l - 1], neighborsUp); + } + } + else + { + for (var l = layerGroups.Count - 2; l >= 0; l--) + { + SortByBarycenter(layerGroups[l], layerGroups[l + 1], neighborsDown); + } + } + } + } + + /// + /// Sorts a layer by the average position of each node's neighbors in the adjacent layer; nodes + /// with no 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; + } + + // Compute a stable sort key: Barycenter when neighbors exist, else current index. + var keyed = new List<(int Node, double Key, int Original)>(); + 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; + } + } + + /// Number of x-coordinate alignment sweeps (down + up) used to straighten the flow. + private const int AlignmentSweeps = 8; + + /// + /// Assigns absolute coordinates: layers stacked vertically, nodes within a layer ordered and + /// given x-coordinates that align each node near the average position of its neighbors so the + /// flow forms a straight spine instead of left-aligning every layer. + /// + private static LayeredResult AssignCoordinates( + IReadOnlyList nodes, + List> layerGroups, + int[] layers, + List edges, + double layerGap, + double nodeGap, + double padding) + { + var n = nodes.Count; + var rects = new PackedRect[n]; + var half = new double[n]; + for (var i = 0; i < n; i++) + { + half[i] = nodes[i].Width / 2.0; + } + + // Layer heights and cumulative Y positions. + var layerY = new double[layerGroups.Count]; + var y = padding; + for (var l = 0; l < layerGroups.Count; l++) + { + layerY[l] = y; + var layerHeight = layerGroups[l].Count == 0 ? 0.0 : layerGroups[l].Max(i => nodes[i].Height); + y += layerHeight + layerGap; + } + + // Initial x: centre each layer around 0 (preserving the crossing-reduced order). + var cx = new double[n]; + foreach (var layer in layerGroups) + { + var total = layer.Sum(i => nodes[i].Width) + (Math.Max(0, layer.Count - 1) * nodeGap); + var pos = -total / 2.0; + foreach (var node in layer) + { + cx[node] = pos + half[node]; + pos += nodes[node].Width + nodeGap; + } + } + + // Neighbor lists in adjacent layers. + var up = new List[n]; + var down = new List[n]; + for (var i = 0; i < n; i++) + { + up[i] = []; + down[i] = []; + } + + foreach (var e in edges) + { + down[e.From].Add(e.To); + up[e.To].Add(e.From); + } + + // Relaxation: alternately pull each node toward the average centre of its Neighbors, + // resolving overlaps within the layer while preserving order. + for (var sweep = 0; sweep < AlignmentSweeps; sweep++) + { + if (sweep % 2 == 0) + { + for (var l = 1; l < layerGroups.Count; l++) + { + AlignLayer(layerGroups[l], up, cx, half, nodeGap); + } + } + else + { + for (var l = layerGroups.Count - 2; l >= 0; l--) + { + AlignLayer(layerGroups[l], down, cx, half, nodeGap); + } + } + } + + // Normalize so the left-most node edge sits at padding, then build rects. + var minLeft = double.MaxValue; + for (var i = 0; i < n; i++) + { + minLeft = Math.Min(minLeft, cx[i] - half[i]); + } + + var shift = padding - (n == 0 ? 0.0 : minLeft); + var maxRight = padding; + for (var l = 0; l < layerGroups.Count; l++) + { + var layerHeight = layerGroups[l].Count == 0 ? 0.0 : layerGroups[l].Max(i => nodes[i].Height); + foreach (var node in layerGroups[l]) + { + var nodeX = cx[node] + shift - half[node]; + var nodeY = layerY[l] + ((layerHeight - nodes[node].Height) / 2.0); + rects[node] = new PackedRect(nodeX, nodeY, nodes[node].Width, nodes[node].Height); + maxRight = Math.Max(maxRight, nodeX + nodes[node].Width); + } + } + + var width = maxRight + padding; + + var lastLayerHeight = layerGroups[^1].Count == 0 + ? 0.0 + : layerGroups[^1].Max(i => nodes[i].Height); + var height = layerY[^1] + lastLayerHeight + padding; + + return new LayeredResult(width, height, rects, layers); + } + + /// + /// Pulls each node in a layer toward the average centre of its Neighbors in the adjacent layer, + /// then resolves overlaps while preserving the layer's order by averaging an order-preserving + /// left-to-right and right-to-left placement (both respect the minimum node gap, so their + /// average does too). + /// + private static void AlignLayer(List layer, List[] Neighbors, double[] cx, double[] half, double nodeGap) + { + var count = layer.Count; + if (count == 0) + { + return; + } + + var desired = new double[count]; + for (var i = 0; i < count; i++) + { + var ns = Neighbors[layer[i]]; + desired[i] = ns.Count > 0 ? ns.Average(v => cx[v]) : cx[layer[i]]; + } + + var lr = new double[count]; + lr[0] = desired[0]; + for (var i = 1; i < count; i++) + { + var minCentre = lr[i - 1] + half[layer[i - 1]] + nodeGap + half[layer[i]]; + lr[i] = Math.Max(desired[i], minCentre); + } + + var rl = new double[count]; + rl[count - 1] = desired[count - 1]; + for (var i = count - 2; i >= 0; i--) + { + var maxCentre = rl[i + 1] - half[layer[i + 1]] - nodeGap - half[layer[i]]; + rl[i] = Math.Min(desired[i], maxCentre); + } + + for (var i = 0; i < count; i++) + { + cx[layer[i]] = (lr[i] + rl[i]) / 2.0; + } + } +} + diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/PortAssigner.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/PortAssigner.cs new file mode 100644 index 00000000..d5e1f727 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/PortAssigner.cs @@ -0,0 +1,122 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.SysML2Tools.Layout.Engine; + +/// +/// A request to place a port on a box, identified by the box rectangle and the point the port's +/// connection travels toward (typically the centre of the connected box). +/// +/// The bounding rectangle of the box that owns the port. +/// The point the port's connection heads toward; selects the box side. +internal readonly record struct PortRequest(Rect Box, Point2D Toward); + +/// +/// The computed placement of a port: its absolute centre and the side of the box it sits on. +/// +/// Absolute X coordinate of the port centre in logical pixels. +/// Absolute Y coordinate of the port centre in logical pixels. +/// The box side the port is attached to. +internal readonly record struct PortPlacement(double CentreX, double CentreY, PortSide Side); + +/// +/// Assigns ports to box sides and distributes multiple ports evenly along each side. +/// +/// +/// Each port is first assigned to the box side whose outward normal best points toward its +/// connection target (a directional heuristic). Ports sharing a side are then spread out at evenly +/// spaced slots, ordered by their target coordinate so connections cross as little as possible. The +/// assigner is deterministic and independent of the SysML model. +/// +internal static class PortAssigner +{ + /// + /// Computes placements for a set of ports that all belong to the same box. + /// + /// + /// The ports to place. Every request should reference the same . + /// + /// One per request, in the same order. + public static IReadOnlyList Assign(IReadOnlyList requests) + { + ArgumentNullException.ThrowIfNull(requests); + + if (requests.Count == 0) + { + return []; + } + + // Group port indices by the side chosen from each port's target direction. + var bySide = new Dictionary>(); + for (var i = 0; i < requests.Count; i++) + { + var side = ChooseSide(requests[i].Box, requests[i].Toward); + if (!bySide.TryGetValue(side, out var list)) + { + list = []; + bySide[side] = list; + } + + list.Add(i); + } + + var placements = new PortPlacement[requests.Count]; + foreach (var (side, indices) in bySide) + { + DistributeAlongSide(requests, side, indices, placements); + } + + return placements; + } + + /// Chooses the box side whose outward normal best points toward the target. + private static PortSide ChooseSide(Rect box, Point2D toward) + { + var cx = box.X + (box.Width / 2.0); + var cy = box.Y + (box.Height / 2.0); + var dx = toward.X - cx; + var dy = toward.Y - cy; + + if (Math.Abs(dx) >= Math.Abs(dy)) + { + return dx >= 0 ? PortSide.Right : PortSide.Left; + } + + return dy >= 0 ? PortSide.Bottom : PortSide.Top; + } + + /// + /// Places the given port indices at evenly spaced slots along the specified box side, ordered + /// by their target coordinate along that side so connections cross as little as possible. + /// + private static void DistributeAlongSide( + IReadOnlyList requests, + PortSide side, + List indices, + PortPlacement[] placements) + { + var box = requests[indices[0]].Box; + var horizontal = side is PortSide.Top or PortSide.Bottom; + + // Order ports by their target's coordinate along the edge to reduce crossings. + indices.Sort((a, b) => horizontal + ? requests[a].Toward.X.CompareTo(requests[b].Toward.X) + : requests[a].Toward.Y.CompareTo(requests[b].Toward.Y)); + + var count = indices.Count; + for (var slot = 0; slot < count; slot++) + { + var fraction = (slot + 1.0) / (count + 1.0); + var (x, y) = side switch + { + PortSide.Top => (box.X + (fraction * box.Width), box.Y), + PortSide.Bottom => (box.X + (fraction * box.Width), box.Y + box.Height), + PortSide.Left => (box.X, box.Y + (fraction * box.Height)), + _ => (box.X + box.Width, box.Y + (fraction * box.Height)), + }; + + placements[indices[slot]] = new PortPlacement(x, y, side); + } + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs new file mode 100644 index 00000000..5848a887 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs @@ -0,0 +1,320 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout.Engine; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Rendering.Internal; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Layout.Internal; + +/// +/// Layout strategy for Action Flow View diagrams. Renders action usages as rounded boxes arranged +/// top-to-bottom in layers by the layered (Sugiyama-style) engine, with a start node entering the +/// initial actions, a done node leaving the final actions, and successions drawn as flow arrows. +/// +internal sealed class ActionFlowViewLayoutStrategy : ILayoutStrategy +{ + /// Minimum width of an action box. + private const double MinActionWidth = 110.0; + + /// Approximate width-per-character factor relative to font size. + private const double CharWidthFactor = 0.62; + + /// Diameter of the start and done markers. + private const double MarkerSize = 20.0; + + /// Vertical space reserved above and below the layers for the start/done markers. + private const double MarkerBand = 50.0; + + /// Clearance kept between routed successions and action boxes. + private const double FlowClearance = 10.0; + + /// An action with its computed box size. + private sealed record ActionItem(string Name, double Width, double Height); + + /// + public LayoutTree BuildLayout(ViewContext context, RenderOptions options) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + var theme = options.Theme; + + var root = FindRoot(context.Workspace); + if (root is null) + { + return new LayoutTree(200.0, 100.0, []); + } + + var (actions, index) = CollectActions(root, theme); + if (actions.Count == 0) + { + return new LayoutTree(200.0, 100.0, []); + } + + var edges = ResolveSuccessions(root, index); + + // Lay the actions out top-to-bottom in layers. + var layered = LayeredLayoutEngine.Place( + [.. actions.Select(a => new LayeredNode(a.Width, a.Height))], + [.. edges.Select(e => new LayeredEdge(e.From, e.To))], + layerGap: theme.FontSizeTitle * 3.0, + nodeGap: theme.FontSizeTitle * 2.0, + padding: theme.LabelPadding * 4.0); + + // Shift everything down to leave room for the start marker band. + var rects = new Rect[actions.Count]; + for (var i = 0; i < actions.Count; i++) + { + var r = layered.Rects[i]; + rects[i] = new Rect(r.X, r.Y + MarkerBand, r.Width, r.Height); + } + + var nodes = new List(); + for (var i = 0; i < actions.Count; i++) + { + nodes.Add(MakeActionBox(actions[i], rects[i])); + } + + var crossings = AddSuccessionEdges(edges, rects, nodes); + AddStartAndDone(actions, rects, edges, layered, nodes); + + var width = layered.Width; + var height = layered.Height + (2.0 * MarkerBand); + var warnings = LayoutWarnings.ForCrossings(context.ViewName, crossings); + return new LayoutTree(width, height, nodes) { Warnings = warnings }; + } + + /// Finds the definition with the most successions to use as the diagram root. + private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace) + { + SysmlDefinitionNode? best = null; + var bestScore = -1; + + foreach (var (qualifiedName, node) in workspace.Declarations) + { + if (node is not SysmlDefinitionNode def) + { + continue; + } + + if (StdlibFilter.IsStdlibElement(qualifiedName, workspace.StdlibNames)) + { + continue; + } + + var successions = def.Children.OfType().Count(); + var actions = def.Children.OfType().Count(f => f.FeatureKeyword == "action"); + var score = (successions * 100) + actions; + if (score > bestScore && (successions > 0 || actions > 0)) + { + best = def; + bestScore = score; + } + } + + return best; + } + + /// Collects the action usages of the root definition and builds a name β†’ index lookup. + private static (IReadOnlyList Actions, Dictionary Index) CollectActions( + SysmlDefinitionNode root, + Theme theme) + { + var actions = new List(); + var index = new Dictionary(StringComparer.Ordinal); + + void Add(string name) + { + if (index.ContainsKey(name)) + { + return; + } + + index[name] = actions.Count; + var (width, height) = ComputeActionSize(name, theme); + actions.Add(new ActionItem(name, width, height)); + } + + foreach (var feature in root.Children.OfType()) + { + if (feature.FeatureKeyword == "action" && feature.Name is not null) + { + Add(feature.Name); + } + } + + foreach (var succession in root.Children.OfType()) + { + if (LastSegment(succession.Source) is { } s) + { + Add(s); + } + + if (LastSegment(succession.Target) is { } t) + { + Add(t); + } + } + + return (actions, index); + } + + /// Resolves succession endpoints to action indices via their last name segment. + private static IReadOnlyList<(int From, int To)> ResolveSuccessions(SysmlDefinitionNode root, Dictionary index) + { + var result = new List<(int, int)>(); + foreach (var succession in root.Children.OfType()) + { + var source = LastSegment(succession.Source); + var target = LastSegment(succession.Target); + if (source is not null && target is not null && + index.TryGetValue(source, out var from) && index.TryGetValue(target, out var to) && from != to) + { + result.Add((from, to)); + } + } + + return result; + } + + /// Computes the intrinsic size of an action box. + private static (double Width, double Height) ComputeActionSize(string name, Theme theme) + { + var labelWidth = (name.Length * theme.FontSizeTitle * CharWidthFactor) + (4.0 * theme.LabelPadding); + var width = Math.Max(MinActionWidth, labelWidth); + var height = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true) + theme.LabelPadding; + return (width, height); + } + + /// Creates a rounded-rectangle action box at the given position. + private static LayoutBox MakeActionBox(ActionItem action, Rect rect) => + new( + X: rect.X, + Y: rect.Y, + Width: rect.Width, + Height: rect.Height, + Label: action.Name, + Depth: 1, + Shape: BoxShape.RoundedRectangle, + Compartments: [], + Children: [], + Keyword: "action"); + + /// + /// Adds the succession flow edges (top-to-bottom) between action boxes, returning the number + /// that had to cross a box. + /// + private static int AddSuccessionEdges( + IReadOnlyList<(int From, int To)> edges, + Rect[] rects, + List nodes) + { + var crossings = 0; + foreach (var (from, to) in edges) + { + var source = new Point2D(rects[from].X + (rects[from].Width / 2.0), rects[from].Y + rects[from].Height); + var target = new Point2D(rects[to].X + (rects[to].Width / 2.0), rects[to].Y); + + var obstacles = new List(); + for (var i = 0; i < rects.Length; i++) + { + if (i != from && i != to) + { + obstacles.Add(rects[i]); + } + } + + var route = ChannelRouter.RouteWithStatus( + source, target, obstacles, FlowClearance, + sourceSide: PortSide.Bottom, targetSide: PortSide.Top); + if (route.Crossed) + { + crossings++; + } + + nodes.Add(new LayoutLine( + Waypoints: route.Waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Filled, + LineStyle: LineStyle.Solid, + MidpointLabel: null)); + } + + return crossings; + } + + /// + /// Adds the start marker (filled circle) entering the actions with no predecessor and the done + /// marker (bullseye) leaving the actions with no successor. + /// + private static void AddStartAndDone( + IReadOnlyList actions, + Rect[] rects, + IReadOnlyList<(int From, int To)> edges, + LayeredResult layered, + List nodes) + { + var hasIncoming = new bool[actions.Count]; + var hasOutgoing = new bool[actions.Count]; + foreach (var (from, to) in edges) + { + hasOutgoing[from] = true; + hasIncoming[to] = true; + } + + var fallbackX = layered.Width / 2.0; + + // Centre the start marker over the action(s) it enters so the entry arrow stays vertical. + var starts = Enumerable.Range(0, actions.Count).Where(i => !hasIncoming[i]).ToList(); + var startX = starts.Count > 0 + ? starts.Average(i => rects[i].X + (rects[i].Width / 2.0)) + : fallbackX; + var startY = MarkerBand / 2.0; + nodes.Add(new LayoutBadge(startX, startY, MarkerSize, BadgeShape.FilledCircle, null)); + foreach (var i in starts) + { + nodes.Add(FlowLine(new Point2D(startX, startY + (MarkerSize / 2.0)), + new Point2D(rects[i].X + (rects[i].Width / 2.0), rects[i].Y))); + } + + // Centre the done marker under the action(s) that reach it. + var ends = Enumerable.Range(0, actions.Count).Where(i => !hasOutgoing[i]).ToList(); + var doneX = ends.Count > 0 + ? ends.Average(i => rects[i].X + (rects[i].Width / 2.0)) + : fallbackX; + var doneY = MarkerBand + layered.Height + (MarkerBand / 2.0); + nodes.Add(new LayoutBadge(doneX, doneY, MarkerSize, BadgeShape.Bullseye, null)); + foreach (var i in ends) + { + nodes.Add(FlowLine(new Point2D(rects[i].X + (rects[i].Width / 2.0), rects[i].Y + rects[i].Height), + new Point2D(doneX, doneY - (MarkerSize / 2.0)))); + } + } + + /// Builds a straight downward flow line with a filled arrowhead at the target. + private static LayoutLine FlowLine(Point2D source, Point2D target) => + new( + Waypoints: Math.Abs(source.X - target.X) < 1e-9 + ? [source, target] + : [source, new Point2D(source.X, (source.Y + target.Y) / 2.0), new Point2D(target.X, (source.Y + target.Y) / 2.0), target], + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Filled, + LineStyle: LineStyle.Solid, + MidpointLabel: null); + + /// Returns the last ::-separated segment of a qualified reference, or null. + private static string? LastSegment(string? reference) + { + if (string.IsNullOrEmpty(reference)) + { + return null; + } + + var sep = reference.LastIndexOf("::", StringComparison.Ordinal); + return sep >= 0 ? reference[(sep + 2)..] : reference; + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs new file mode 100644 index 00000000..b14d1f59 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs @@ -0,0 +1,167 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Rendering.Internal; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Layout.Internal; + +/// +/// Layout strategy for Browser View diagrams. Presents the membership hierarchy of the workspace's +/// user-defined elements as an indented tree of rows, with connector lines from each parent to its +/// children. +/// +/// +/// The tree is derived from the qualified-name hierarchy: an element A::B::C is a child of +/// A::B. Each row is a small box indented by its depth; layout is pure arithmetic. +/// +internal sealed class BrowserViewLayoutStrategy : ILayoutStrategy +{ + /// Horizontal indentation per depth level. + private const double Indent = 28.0; + + /// Approximate width-per-character factor relative to font size. + private const double CharWidthFactor = 0.62; + + /// A node in the membership tree. + private sealed record TreeNode(string QualifiedName, string Label, string? Keyword, List Children); + + /// + public LayoutTree BuildLayout(ViewContext context, RenderOptions options) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + var theme = options.Theme; + + var roots = BuildForest(context.Workspace); + if (roots.Count == 0) + { + return new LayoutTree(200.0, 100.0, []); + } + + var nodes = new List(); + var cursorY = theme.LabelPadding * 2.0; + var maxRight = 0.0; + var rowHeight = theme.FontSizeTitle + (2.0 * theme.LabelPadding); + + foreach (var root in roots) + { + EmitNode(root, depth: 0, theme, rowHeight, nodes, ref cursorY, ref maxRight, parentCentreY: null, parentX: 0); + } + + var width = maxRight + (theme.LabelPadding * 2.0); + var height = cursorY + theme.LabelPadding; + return new LayoutTree(width, height, nodes); + } + + /// + /// Builds the membership forest from the non-stdlib declarations using their qualified-name + /// nesting (parent = prefix before the last ::). + /// + private static IReadOnlyList BuildForest(SysmlWorkspace workspace) + { + var byName = new Dictionary(StringComparer.Ordinal); + var roots = new List(); + + // Deterministic order: sort qualified names so parents precede children. + var names = workspace.Declarations.Keys + .Where(qn => !StdlibFilter.IsStdlibElement(qn, workspace.StdlibNames)) + .OrderBy(qn => qn, StringComparer.Ordinal) + .ToList(); + + foreach (var qn in names) + { + var node = workspace.Declarations[qn]; + var label = LastSegment(qn); + var keyword = KeywordOf(node); + var tree = new TreeNode(qn, label, keyword, []); + byName[qn] = tree; + + var sep = qn.LastIndexOf("::", StringComparison.Ordinal); + if (sep >= 0 && byName.TryGetValue(qn[..sep], out var parent)) + { + parent.Children.Add(tree); + } + else + { + roots.Add(tree); + } + } + + return roots; + } + + /// Recursively emits a tree node row and its descendants, advancing the Y cursor. + private static void EmitNode( + TreeNode node, + int depth, + Theme theme, + double rowHeight, + List nodes, + ref double cursorY, + ref double maxRight, + double? parentCentreY, + double parentX) + { + var x = (theme.LabelPadding * 2.0) + (depth * Indent); + var y = cursorY; + var label = node.Keyword is { Length: > 0 } k ? $"{k} {node.Label}" : node.Label; + var boxWidth = (label.Length * theme.FontSizeBody * CharWidthFactor) + (4.0 * theme.LabelPadding); + var centreY = y + (rowHeight / 2.0); + + // Connector line from the parent's bottom-left stem down to this row, then across to the box. + if (parentCentreY is { } pcy) + { + nodes.Add(new LayoutLine( + Waypoints: [new Point2D(parentX, pcy), new Point2D(parentX, centreY), new Point2D(x, centreY)], + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.None, + LineStyle: LineStyle.Solid, + MidpointLabel: null)); + } + + nodes.Add(new LayoutBox( + X: x, + Y: y, + Width: boxWidth, + Height: rowHeight, + Label: label, + Depth: Math.Min(depth, 3), + Shape: BoxShape.Rectangle, + Compartments: [], + Children: [])); + + maxRight = Math.Max(maxRight, x + boxWidth); + cursorY += rowHeight + (theme.LabelPadding / 2.0); + + // Children hang from a vertical stem that drops from this row's bottom-left, so it never + // crosses over this node's own box or text. + var stemX = x + (Indent / 2.0); + var stemTopY = y + rowHeight; + foreach (var child in node.Children) + { + EmitNode(child, depth + 1, theme, rowHeight, nodes, ref cursorY, ref maxRight, stemTopY, stemX); + } + } + + /// Returns a short keyword for a declaration node, or null when none applies. + private static string? KeywordOf(SysmlNode node) => node switch + { + SysmlPackageNode => "package", + SysmlDefinitionNode def => string.IsNullOrEmpty(def.DefinitionKeyword) ? "def" : def.DefinitionKeyword, + SysmlFeatureNode feature => string.IsNullOrEmpty(feature.FeatureKeyword) ? null : feature.FeatureKeyword, + SysmlViewNode => "view def", + _ => null, + }; + + /// Returns the last ::-separated segment of a qualified name. + private static string LastSegment(string qualifiedName) + { + var sep = qualifiedName.LastIndexOf("::", StringComparison.Ordinal); + return sep >= 0 ? qualifiedName[(sep + 2)..] : qualifiedName; + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index 570555cd..07592b8e 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -2,6 +2,7 @@ // Copyright (c) DemaConsulting. All rights reserved. // +using DemaConsulting.SysML2Tools.Layout.Engine; using DemaConsulting.SysML2Tools.Rendering; using DemaConsulting.SysML2Tools.Rendering.Internal; using DemaConsulting.SysML2Tools.Semantic; @@ -10,405 +11,546 @@ namespace DemaConsulting.SysML2Tools.Layout.Internal; /// -/// Layout strategy for GeneralView diagrams that renders all user-defined part def -/// elements grouped by their parent package in a two-column grid. +/// Layout strategy for GeneralView diagrams. Renders every user-defined def element +/// (part, port, interface, requirement, action, …) as a keyword-labelled box, groups boxes by +/// their owning package inside folder-shaped containers, and routes specialization edges +/// orthogonally around the boxes. /// /// -/// Standard-library declarations are filtered out using . Only -/// instances with -/// equal to "part def" are laid out. -/// When no user-defined part defs are found, a minimal canvas -/// LayoutTree(200.0, 100.0, []) is returned. +/// Box placement uses at two levels β€” definition boxes within a +/// package folder, and the folders themselves across the canvas. Specialization (generalization) +/// edges are routed with so they avoid unrelated boxes. Standard-library +/// declarations are excluded via . /// internal sealed class GeneralViewLayoutStrategy : ILayoutStrategy { - /// Layout margin around the entire diagram canvas. - private const double Margin = 20.0; + /// Minimum width of a definition box in logical pixels. + private const double MinBoxWidth = 130.0; - /// Horizontal gap between the two layout columns. - private const double ColumnGap = 30.0; + /// Approximate width-per-character factor relative to font size. + private const double CharWidthFactor = 0.62; - /// Vertical gap between group rows. - private const double RowGap = 20.0; + /// Clearance kept between routed edges and boxes. + private const double EdgeClearance = 12.0; - /// Minimum box width in logical pixels. - private const double MinBoxWidth = 120.0; + /// A user-defined definition together with its computed box size and supertypes. + private sealed record DefBox( + string QualifiedName, + string SimpleName, + string Keyword, + IReadOnlyList SupertypeNames, + IReadOnlyList Compartments, + double Width, + double Height); - /// Minimum box height in logical pixels. - private const double MinBoxHeight = 40.0; + /// A placed definition box with absolute coordinates, used for edge anchoring. + private sealed record PlacedBox(string QualifiedName, string SimpleName, double X, double Y, double Width, double Height); /// public LayoutTree BuildLayout(ViewContext context, RenderOptions options) { - // Collect all user-defined part defs from the workspace - var userPartDefs = CollectUserPartDefs(context.Workspace); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); - // Return minimal canvas when no user part defs are present - if (userPartDefs.Count == 0) + var theme = options.Theme; + + // Collect all user-defined definitions, sized for rendering. + var defs = CollectDefinitions(context.Workspace, theme); + if (defs.Count == 0) { return new LayoutTree(200.0, 100.0, []); } - // Group part defs by parent package (prefix before the last "::") - var groups = GroupByPackage(userPartDefs); + // Group definitions by their owning package (prefix before the last "::"). + var groups = GroupByPackage(defs); + + // Place groups (folders) and standalone definitions across the canvas. + var (nodes, placed, canvasWidth, canvasHeight) = PlaceGroups(groups, theme, options.DepthLimit); - // Lay out groups in a two-column grid and collect all top-level nodes - return BuildGridLayout(groups, options.Theme, options.DepthLimit); + // Route specialization edges between placed boxes. + var (edges, crossings) = BuildSpecializationEdges(defs, placed); + nodes.AddRange(edges); + + var warnings = LayoutWarnings.ForCrossings(context.ViewName, crossings); + return new LayoutTree(canvasWidth, canvasHeight, nodes) { Warnings = warnings }; } /// - /// Collects all user-defined part def declarations from the workspace, - /// filtering out standard-library elements. + /// Collects every user-defined from the workspace and computes + /// each box's intrinsic size from its keyword and name. /// - /// The workspace whose declarations are scanned. - /// - /// A list of (qualifiedName, node) pairs for every user-defined part def. - /// - private static IReadOnlyList<(string QualifiedName, SysmlDefinitionNode Node)> CollectUserPartDefs( - SysmlWorkspace workspace) + private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace, Theme theme) { - var result = new List<(string, SysmlDefinitionNode)>(); + var result = new List(); foreach (var (qualifiedName, declaration) in workspace.Declarations) { - // Skip non-definition nodes and non-part-def definitions - if (declaration is not SysmlDefinitionNode def || - def.DefinitionKeyword != "part def") + if (declaration is not SysmlDefinitionNode def) { continue; } - // Skip stdlib elements identified by their qualified-name prefix - if (StdlibFilter.IsStdlibElement(qualifiedName)) + if (StdlibFilter.IsStdlibElement(qualifiedName, workspace.StdlibNames)) { continue; } - result.Add((qualifiedName, def)); + var simpleName = def.Name ?? qualifiedName; + var keyword = string.IsNullOrEmpty(def.DefinitionKeyword) ? "def" : def.DefinitionKeyword; + + // Build compartments from the definition's owned usages (attributes, ports, parts, …). + var compartments = BuildCompartments(def); + + var (width, height) = ComputeBoxSize(simpleName, keyword, compartments, theme); + result.Add(new DefBox(qualifiedName, simpleName, keyword, def.SupertypeNames, compartments, width, height)); } return result; } /// - /// Groups part-def entries by their parent package. + /// Builds compartments for a definition by grouping its owned usage features by keyword and + /// formatting each as a name : Type [n] row. /// - /// User-defined part defs to group. - /// - /// An ordered list of (packageName, items) groups, where packageName is - /// the prefix before the last :: separator, or "" for top-level defs. - /// - private static IReadOnlyList<(string PackageName, IReadOnlyList<(string QualifiedName, SysmlDefinitionNode Node)> Items)> - GroupByPackage(IReadOnlyList<(string QualifiedName, SysmlDefinitionNode Node)> partDefs) + private static IReadOnlyList BuildCompartments(SysmlDefinitionNode def) { - // Use ordered dictionary to preserve insertion order of groups - var groups = new Dictionary>(StringComparer.Ordinal); + // Preserve keyword first-seen order so compartments appear in declaration order. + var order = new List(); + var groups = new Dictionary>(StringComparer.Ordinal); - foreach (var (qualifiedName, node) in partDefs) + foreach (var child in def.Children) { - // Extract parent package name from the qualified name - var lastSeparator = qualifiedName.LastIndexOf("::", StringComparison.Ordinal); - var packageName = lastSeparator >= 0 - ? qualifiedName[..lastSeparator] - : string.Empty; + if (child is not SysmlFeatureNode feature) + { + continue; + } - if (!groups.TryGetValue(packageName, out var group)) + var keyword = string.IsNullOrEmpty(feature.FeatureKeyword) ? "feature" : feature.FeatureKeyword; + if (!groups.TryGetValue(keyword, out var rows)) { - group = []; - groups[packageName] = group; + rows = []; + groups[keyword] = rows; + order.Add(keyword); } - group.Add((qualifiedName, node)); + rows.Add(FormatFeatureRow(feature)); } - return groups - .Select(kvp => (kvp.Key, (IReadOnlyList<(string, SysmlDefinitionNode)>)kvp.Value)) - .ToList(); + return [.. order.Select(k => new LayoutCompartment(Pluralize(k), groups[k]))]; } - /// - /// Builds a two-column grid layout from the grouped part defs. - /// - /// Part-def groups ordered by package name. - /// Visual theme providing size and color parameters. - /// Maximum nesting depth to render; 0 means unlimited. - /// A fully resolved with all box positions computed. - private static LayoutTree BuildGridLayout( - IReadOnlyList<(string PackageName, IReadOnlyList<(string QualifiedName, SysmlDefinitionNode Node)> Items)> groups, - Theme theme, - int depthLimit) + /// Formats a usage feature as a compartment row: name : Type [n]. + private static string FormatFeatureRow(SysmlFeatureNode feature) + { + var name = feature.Name ?? string.Empty; + var typing = feature.FeatureTyping is { Length: > 0 } t ? $" : {t}" : string.Empty; + var multiplicity = feature.Multiplicity is { Length: > 0 } m ? $" {m}" : string.Empty; + var row = $"{name}{typing}{multiplicity}".Trim(); + return row.Length == 0 ? "\u2014" : row; + } + + /// Returns a simple plural form of a usage keyword for use as a compartment title. + private static string Pluralize(string keyword) => keyword switch { - // Compute the width required for each group box - var groupWidths = groups.Select(g => ComputeGroupWidth(g.PackageName, g.Items, theme)).ToList(); - var groupHeights = groups.Select(g => ComputeGroupHeight(g.Items, theme)).ToList(); - - // Determine column widths from the maximum group width in each column - var col0Width = 0.0; - var col1Width = 0.0; - for (var i = 0; i < groups.Count; i++) + "ref" => "references", + _ => keyword + "s", + }; + + /// Computes the intrinsic box size needed for the title and any compartments. + private static (double Width, double Height) ComputeBoxSize( + string name, + string keyword, + IReadOnlyList compartments, + Theme theme) + { + var nameWidth = (name.Length * theme.FontSizeTitle * CharWidthFactor) + (2.0 * theme.LabelPadding); + var keywordWidth = ((keyword.Length + 2) * theme.FontSizeBody * CharWidthFactor) + (2.0 * theme.LabelPadding); + var width = Math.Max(MinBoxWidth, Math.Max(nameWidth, keywordWidth)); + + // Widen to fit the longest compartment title or row. + foreach (var compartment in compartments) { - if (i % 2 == 0) + if (compartment.Title is { } title) { - col0Width = Math.Max(col0Width, groupWidths[i]); + width = Math.Max(width, (title.Length * theme.FontSizeBody * CharWidthFactor) + (2.0 * theme.LabelPadding)); } - else + + foreach (var row in compartment.Rows) { - col1Width = Math.Max(col1Width, groupWidths[i]); + width = Math.Max(width, (row.Length * theme.FontSizeBody * CharWidthFactor) + (3.0 * theme.LabelPadding)); } } - // Suppress unused variable warning when col1Width is never consumed in grid positioning - _ = col1Width; - - // Position each group box in the grid - var nodes = new List(); - var cursorX1 = Margin + col0Width + ColumnGap; - var cursorY0 = Margin; - var cursorY1 = Margin; - var maxX = Margin; - var maxY = Margin; - - for (var i = 0; i < groups.Count; i++) + // Title area holds the keyword line and the name line; add a little body breathing room. + var height = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true) + theme.LabelPadding; + foreach (var compartment in compartments) { - var (packageName, items) = groups[i]; - var gw = groupWidths[i]; - var gh = groupHeights[i]; - - double boxX; - double boxY; - if (i % 2 == 0) - { - // Left column - boxX = Margin; - boxY = cursorY0; - cursorY0 += gh + RowGap; - } - else - { - // Right column - boxX = cursorX1; - boxY = cursorY1; - cursorY1 += gh + RowGap; - } + height += ComputeCompartmentHeight(compartment, theme); + } - // Build child part-def boxes - var children = BuildChildBoxes(items, boxX, boxY, gw, theme, depthLimit); - var label = string.IsNullOrEmpty(packageName) ? null : packageName; - var groupBox = new LayoutBox(boxX, boxY, gw, gh, label, 0, BoxShape.Rectangle, [], children); - nodes.Add(groupBox); + return (width, height); + } - maxX = Math.Max(maxX, boxX + gw); - maxY = Math.Max(maxY, boxY + gh); + /// + /// Computes the rendered height of a compartment, matching the renderer's layout: an optional + /// title row followed by one row per entry. + /// + private static double ComputeCompartmentHeight(LayoutCompartment compartment, Theme theme) + { + var height = 0.0; + if (compartment.Title is not null) + { + height += theme.LabelPadding + theme.FontSizeBody + theme.LabelPadding; } - // Add specialization lines for part defs with supertypes - AddSpecializationLines(groups, nodes); + height += compartment.Rows.Count * (theme.LabelPadding + theme.FontSizeBody); - var canvasWidth = maxX + Margin; - var canvasHeight = maxY + Margin; - return new LayoutTree(canvasWidth, canvasHeight, nodes); + // Bottom gap added by the renderer after the last row. + height += theme.LabelPadding; + return height; } /// - /// Computes the minimum width of a group box based on its label and child part defs. + /// Groups definitions by their parent package name (the qualified-name prefix before the last + /// ::), preserving first-seen order. Top-level definitions use an empty package key. /// - /// Parent package label text. - /// Part-def items in the group. - /// Visual theme for font measurements. - /// Minimum required width in logical pixels. - private static double ComputeGroupWidth( - string packageName, - IReadOnlyList<(string QualifiedName, SysmlDefinitionNode Node)> items, - Theme theme) + private static IReadOnlyList<(string Package, List Items)> GroupByPackage(IReadOnlyList defs) { - // Start with the package label width - var labelWidth = packageName.Length * theme.FontSizeTitle * 0.6 + 2 * theme.LabelPadding; - var maxWidth = Math.Max(MinBoxWidth, labelWidth); + var order = new List(); + var map = new Dictionary>(StringComparer.Ordinal); - // Expand to fit each child label - foreach (var (qualifiedName, node) in items) + foreach (var def in defs) { - var childLabel = node.Name ?? qualifiedName; - var childWidth = childLabel.Length * theme.FontSizeTitle * 0.6 + 4 * theme.LabelPadding; - maxWidth = Math.Max(maxWidth, Math.Max(MinBoxWidth, childWidth)); + var sep = def.QualifiedName.LastIndexOf("::", StringComparison.Ordinal); + var package = sep >= 0 ? def.QualifiedName[..sep] : string.Empty; + + if (!map.TryGetValue(package, out var list)) + { + list = []; + map[package] = list; + order.Add(package); + } + + list.Add(def); } - return maxWidth + 2 * theme.LabelPadding; + return [.. order.Select(p => (p, map[p]))]; } /// - /// Computes the total height of a group box based on its title area and child box heights. + /// Places each package group as a folder box (with its definitions packed inside) and each + /// top-level definition as a standalone box, packing all blocks across the canvas. /// - /// Part-def items in the group. - /// Visual theme for font measurements. - /// Minimum required height in logical pixels. - private static double ComputeGroupHeight( - IReadOnlyList<(string QualifiedName, SysmlDefinitionNode Node)> items, - Theme theme) + private static (List Nodes, List Placed, double Width, double Height) PlaceGroups( + IReadOnlyList<(string Package, List Items)> groups, + Theme theme, + int depthLimit) { - // Title area height plus padding - var titleHeight = theme.FontSizeTitle + 2 * theme.LabelPadding; + var margin = 2.0 * theme.LabelPadding; + var hGap = 4.0 * theme.LabelPadding; + + // Vertical gap between packed rows. Kept generous so specialization edges between + // vertically-adjacent boxes have room for their arrowheads and a visible line segment. + var vGap = 5.0 * theme.LabelPadding; + + // Reserve the full title area (package keyword + name) above a folder's contents so the + // label never overlaps the first child box. The renderer draws the smaller tab notch within. + var folderTitleHeight = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true); - // Sum child box heights with vertical spacing - var childrenHeight = items.Count * MinBoxHeight + (items.Count + 1) * theme.LabelPadding; + // Folder contents sit at depth 1; truncate them when the depth limit forbids that level. + var truncateFolderContents = depthLimit > 0 && depthLimit <= 1; + + // Pre-compute the outer size of each top-level block (folder or standalone box). + var blocks = new List(); + foreach (var (package, items) in groups) + { + if (string.IsNullOrEmpty(package)) + { + // Top-level definitions are individual blocks (no folder). + foreach (var def in items) + { + blocks.Add(new BlockPlan(null, [def], def.Width, def.Height)); + } + } + else if (truncateFolderContents) + { + // Replace the folder's definition boxes with a single ellipsis indicator. + var ellipsisWidth = Math.Max(MinBoxWidth, (2.0 * margin) + (items.Count.ToString(System.Globalization.CultureInfo.InvariantCulture).Length * 8.0) + 60.0); + var ellipsisHeight = (2.0 * margin) + theme.FontSizeTitle; + blocks.Add(new BlockPlan(package, items, ellipsisWidth, folderTitleHeight + ellipsisHeight) { Truncated = true }); + } + else + { + // Pack the package's definitions to size the folder content region. + var inner = ContainmentPacker.Pack( + [.. items.Select(d => new PackItem(d.Width, d.Height))], + maxContentWidth: ComputePackWidth(items), + horizontalGap: hGap, + verticalGap: vGap, + padding: margin); + + var folderWidth = inner.Width; + var folderHeight = folderTitleHeight + inner.Height; + blocks.Add(new BlockPlan(package, items, folderWidth, folderHeight) { Inner = inner }); + } + } + + // Pack the blocks across the canvas. + var outer = ContainmentPacker.Pack( + [.. blocks.Select(b => new PackItem(b.Width, b.Height))], + maxContentWidth: ComputeCanvasWidth(blocks), + horizontalGap: hGap, + verticalGap: vGap, + padding: margin); + + var nodes = new List(); + var placed = new List(); - return titleHeight + childrenHeight; + for (var i = 0; i < blocks.Count; i++) + { + PlaceBlock(blocks[i], outer.Rects[i], folderTitleHeight, theme, nodes, placed); + } + + return (nodes, placed, outer.Width, outer.Height); } - /// - /// Builds the child nodes for part defs within a group. - /// - /// Part-def items to lay out. - /// Left edge X of the parent group box. - /// Top edge Y of the parent group box. - /// Width of the parent group box. - /// Visual theme for font and size measurements. - /// Maximum nesting depth to render; 0 means unlimited. - /// List of child nodes with absolute coordinates. - private static IReadOnlyList BuildChildBoxes( - IReadOnlyList<(string QualifiedName, SysmlDefinitionNode Node)> items, - double groupX, - double groupY, - double groupWidth, + /// Emits the layout nodes for one placed block and records its definition boxes. + private static void PlaceBlock( + BlockPlan block, + PackedRect rect, + double folderTitleHeight, Theme theme, - int depthLimit) + List nodes, + List placed) { - var children = new List(); + if (block.Package is null) + { + // Standalone top-level definition box. + var def = block.Items[0]; + nodes.Add(MakeDefBox(def, rect.X, rect.Y, depth: 0)); + placed.Add(new PlacedBox(def.QualifiedName, def.SimpleName, rect.X, rect.Y, def.Width, def.Height)); + return; + } - // Start below the group title area - var titleHeight = theme.FontSizeTitle + 2 * theme.LabelPadding; - var childX = groupX + theme.LabelPadding; - var childWidth = groupWidth - 2 * theme.LabelPadding; - var cursorY = groupY + titleHeight + theme.LabelPadding; + var children = new List(); - // Child part-def boxes are at depth 1; when the depth limit is active and met, - // replace all children with a single ellipsis label to indicate truncation - if (depthLimit > 0 && 1 >= depthLimit) + if (block.Truncated) { + // Show a visible truncation indicator instead of the hidden definition boxes. children.Add(new LayoutLabel( - X: childX, - Y: cursorY, - MaxWidth: childWidth, - Text: "…", + X: rect.X + theme.LabelPadding, + Y: rect.Y + folderTitleHeight + theme.LabelPadding + (theme.FontSizeTitle / 2.0), + MaxWidth: block.Width - (2.0 * theme.LabelPadding), + Text: $"+{block.Items.Count} more\u2026", Align: TextAlign.Center, Weight: FontWeight.Regular, Style: FontStyle.Normal, FontSize: theme.FontSizeTitle)); - return children; } - - foreach (var (qualifiedName, node) in items) + else { - var label = node.Name ?? qualifiedName; - var childBox = new LayoutBox( - X: childX, - Y: cursorY, - Width: childWidth, - Height: MinBoxHeight, - Label: label, - Depth: 1, - Shape: BoxShape.Rectangle, - Compartments: [], - Children: []); - children.Add(childBox); - cursorY += MinBoxHeight + theme.LabelPadding; + // Folder containing packed definition boxes. + var inner = block.Inner!; + for (var k = 0; k < block.Items.Count; k++) + { + var def = block.Items[k]; + var childRect = inner.Rects[k]; + var absX = rect.X + childRect.X; + var absY = rect.Y + folderTitleHeight + childRect.Y; + children.Add(MakeDefBox(def, absX, absY, depth: 1)); + placed.Add(new PlacedBox(def.QualifiedName, def.SimpleName, absX, absY, def.Width, def.Height)); + } } - return children; + nodes.Add(new LayoutBox( + X: rect.X, + Y: rect.Y, + Width: block.Width, + Height: block.Height, + Label: SimplePackageName(block.Package), + Depth: 0, + Shape: BoxShape.Folder, + Compartments: [], + Children: children, + Keyword: "package")); } + /// Creates a definition at the given absolute position. + private static LayoutBox MakeDefBox(DefBox def, double x, double y, int depth) => + new( + X: x, + Y: y, + Width: def.Width, + Height: def.Height, + Label: def.SimpleName, + Depth: depth, + Shape: BoxShape.Rectangle, + Compartments: def.Compartments, + Children: [], + Keyword: def.Keyword); + /// - /// Adds nodes for specialization relationships between part defs - /// that declare supertypes. + /// Builds specialization (generalization) edges between placed definition boxes, returning the + /// edges and the number that could not be routed without crossing a box. /// - /// All part-def groups used to resolve supertype positions. - /// Top-level node list to which lines are appended. - /// - /// Lines use at the source (subtype) end and - /// at the target (supertype) end, following the - /// SysML convention that the open arrowhead points toward the general type. - /// Only supertypes resolvable within the same set of user part defs are connected; - /// missing supertypes produce no line. - /// - private static void AddSpecializationLines( - IReadOnlyList<(string PackageName, IReadOnlyList<(string QualifiedName, SysmlDefinitionNode Node)> Items)> groups, - List nodes) + private static (List Edges, int Crossings) BuildSpecializationEdges( + IReadOnlyList defs, + IReadOnlyList placed) { - // Build a lookup table from qualifiedName -> LayoutBox position - var boxPositions = BuildBoxPositionLookup(nodes); + var edges = new List(); + var crossings = 0; + + // Index placed boxes by both qualified and simple name for supertype resolution. + var byQualified = new Dictionary(StringComparer.Ordinal); + var bySimple = new Dictionary(StringComparer.Ordinal); + foreach (var p in placed) + { + byQualified.TryAdd(p.QualifiedName, p); + bySimple.TryAdd(p.SimpleName, p); + } - foreach (var (_, items) in groups) + foreach (var def in defs) { - foreach (var (qualifiedName, node) in items) + if (!byQualified.TryGetValue(def.QualifiedName, out var fromBox)) + { + continue; + } + + foreach (var supertype in def.SupertypeNames) { - foreach (var supertypeName in node.SupertypeNames) + if (!TryResolve(supertype, byQualified, bySimple, out var target) || + target!.QualifiedName == def.QualifiedName) { - // Only draw lines to supertypes that are in our layout - if (!boxPositions.TryGetValue(qualifiedName, out var fromBox) || - !boxPositions.TryGetValue(supertypeName, out var toBox)) - { - continue; - } - - // Create a simple 3-segment orthogonal line from subtype bottom to supertype bottom - var fromX = fromBox.X + fromBox.Width / 2.0; - var fromY = fromBox.Y + fromBox.Height; - var toX = toBox.X + toBox.Width / 2.0; - var toY = toBox.Y + toBox.Height; - var midY = (fromY + toY) / 2.0; - - var waypoints = new List - { - new(fromX, fromY), - new(fromX, midY), - new(toX, midY), - new(toX, toY) - }; - - nodes.Add(new LayoutLine( - Waypoints: waypoints, - SourceArrowhead: ArrowheadStyle.None, - TargetArrowhead: ArrowheadStyle.Open, - LineStyle: LineStyle.Solid, - MidpointLabel: null)); + continue; + } + + var (edge, crossed) = RouteEdge(fromBox, target, placed); + edges.Add(edge); + if (crossed) + { + crossings++; } } } + + return (edges, crossings); + } + + /// Resolves a supertype reference to a placed box by qualified then simple name. + private static bool TryResolve( + string reference, + Dictionary byQualified, + Dictionary bySimple, + out PlacedBox? target) + { + if (byQualified.TryGetValue(reference, out var q)) + { + target = q; + return true; + } + + // Fall back to the last segment of the reference matched against simple names. + var sep = reference.LastIndexOf("::", StringComparison.Ordinal); + var simple = sep >= 0 ? reference[(sep + 2)..] : reference; + if (bySimple.TryGetValue(simple, out var s)) + { + target = s; + return true; + } + + target = null; + return false; } /// - /// Builds a flat lookup dictionary from qualified name to by - /// walking the tree of top-level nodes recursively. + /// Routes a single specialization edge from a subtype box to its supertype box, returning the + /// edge and whether it had to cross another box. /// - /// Top-level nodes to search. - /// A dictionary mapping qualified name to the corresponding . - private static Dictionary BuildBoxPositionLookup(IReadOnlyList nodes) + private static (LayoutLine Edge, bool Crossed) RouteEdge(PlacedBox from, PlacedBox to, IReadOnlyList placed) { - var result = new Dictionary(StringComparer.Ordinal); - CollectBoxes(nodes, result); - return result; + var fromCenter = new Point2D(from.X + (from.Width / 2.0), from.Y + (from.Height / 2.0)); + var toCenter = new Point2D(to.X + (to.Width / 2.0), to.Y + (to.Height / 2.0)); + + var (source, sourceSide) = AnchorToward(from, toCenter); + var (target, targetSide) = AnchorToward(to, fromCenter); + + // Obstacles are all boxes except the two endpoints of this edge. + var obstacles = placed + .Where(b => b.QualifiedName != from.QualifiedName && b.QualifiedName != to.QualifiedName) + .Select(b => new Rect(b.X, b.Y, b.Width, b.Height)) + .ToList(); + + var route = ChannelRouter.RouteWithStatus(source, target, obstacles, EdgeClearance, sourceSide, targetSide); + + // Generalization: open arrowhead points at the supertype (target) end. + var edge = new LayoutLine( + Waypoints: route.Waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Open, + LineStyle: LineStyle.Solid, + MidpointLabel: null); + return (edge, route.Crossed); } /// - /// Recursively collects labeled nodes and their positions. + /// Returns the midpoint of the box side whose outward normal best points at the target, along + /// with that side. /// - /// Nodes to walk. - /// Dictionary to populate. - private static void CollectBoxes(IEnumerable nodes, Dictionary lookup) + private static (Point2D Point, PortSide Side) AnchorToward(PlacedBox box, Point2D target) { - foreach (var node in nodes) - { - if (node is LayoutBox box) - { - // Record by label (which is the simple name for child part-def boxes) - if (box.Label != null) - { - lookup.TryAdd(box.Label, box); - } + var cx = box.X + (box.Width / 2.0); + var cy = box.Y + (box.Height / 2.0); + var dx = target.X - cx; + var dy = target.Y - cy; - // Recurse into children - CollectBoxes(box.Children, lookup); - } + if (Math.Abs(dx) >= Math.Abs(dy)) + { + // Left or right side. + return dx >= 0 + ? (new Point2D(box.X + box.Width, cy), PortSide.Right) + : (new Point2D(box.X, cy), PortSide.Left); } + + // Top or bottom side. + return dy >= 0 + ? (new Point2D(cx, box.Y + box.Height), PortSide.Bottom) + : (new Point2D(cx, box.Y), PortSide.Top); + } + + /// Computes the packing width used to lay out the definitions within a package folder. + private static double ComputePackWidth(IReadOnlyList items) + { + // Target a roughly 4:3 region by packing to the square root of the total item area. + var totalArea = items.Sum(d => (d.Width + 20.0) * (d.Height + 20.0)); + var maxItemWidth = items.Max(d => d.Width); + var target = Math.Sqrt(totalArea) * 1.15; + return Math.Max(maxItemWidth, target); + } + + /// Computes the packing width used to lay out top-level blocks across the canvas. + private static double ComputeCanvasWidth(IReadOnlyList blocks) + { + // Target a roughly 4:3 canvas by packing to the square root of the total block area. + var totalArea = blocks.Sum(b => (b.Width + 30.0) * (b.Height + 30.0)); + var maxBlockWidth = blocks.Max(b => b.Width); + var target = Math.Sqrt(totalArea) * 1.25; + return Math.Max(maxBlockWidth, target); + } + + /// Returns the last segment of a qualified package name for use as a folder label. + private static string SimplePackageName(string package) + { + var sep = package.LastIndexOf("::", StringComparison.Ordinal); + return sep >= 0 ? package[(sep + 2)..] : package; + } + + /// Internal plan for one top-level block (a folder or a standalone definition box). + private sealed record BlockPlan(string? Package, List Items, double Width, double Height) + { + /// Packed inner layout of the folder's definition boxes, when this block is a folder. + public PackResult? Inner { get; init; } + + /// When true, the folder's contents are replaced by an ellipsis truncation indicator. + public bool Truncated { get; init; } } } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs new file mode 100644 index 00000000..c36004d0 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs @@ -0,0 +1,137 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Rendering.Internal; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Layout.Internal; + +/// +/// Layout strategy for Grid View diagrams. Presents the workspace's user-defined definitions as a +/// specialization relationship matrix: rows and columns are the definitions, and a cell is marked +/// where the row definition specializes the column definition. +/// +/// +/// Layout is pure arithmetic via : column widths fit the widest cell and a +/// header row/column are styled distinctly. +/// +internal sealed class GridViewLayoutStrategy : ILayoutStrategy +{ + /// Approximate width-per-character factor relative to font size. + private const double CharWidthFactor = 0.62; + + /// Mark placed in a cell where the row specializes the column. + private const string Mark = "X"; + + /// + public LayoutTree BuildLayout(ViewContext context, RenderOptions options) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + var theme = options.Theme; + + var defs = CollectDefinitions(context.Workspace); + if (defs.Count == 0) + { + return new LayoutTree(200.0, 100.0, []); + } + + // Resolve each definition's supertypes to column indices by simple name. + var indexByName = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < defs.Count; i++) + { + indexByName.TryAdd(defs[i].Name, i); + } + + var rowHeight = theme.FontSizeBody + (2.0 * theme.LabelPadding); + var headerWidth = MaxLabelWidth(defs.Select(d => d.Name), theme.FontSizeBody) + (2.0 * theme.LabelPadding); + var dataWidth = Math.Max(rowHeight, MaxLabelWidth(defs.Select(d => d.Name), theme.FontSizeBody) + (2.0 * theme.LabelPadding)); + + var rows = new List(); + + // Header row: empty corner cell then each definition name as a column header. + var headerCells = new List { new(headerWidth, rowHeight, string.Empty, TextAlign.Center, 1) }; + foreach (var def in defs) + { + headerCells.Add(new LayoutGridCell(dataWidth, rowHeight, def.Name, TextAlign.Center, 1)); + } + + rows.Add(new LayoutGridRow(IsHeader: true, headerCells)); + + // Data rows: header column with the row definition, then a mark where it specializes the column. + foreach (var rowDef in defs) + { + var cells = new List { new(headerWidth, rowHeight, rowDef.Name, TextAlign.Left, 1) }; + var supertypeIndices = ResolveSupertypeIndices(rowDef, indexByName); + for (var col = 0; col < defs.Count; col++) + { + var text = supertypeIndices.Contains(col) ? Mark : string.Empty; + cells.Add(new LayoutGridCell(dataWidth, rowHeight, text, TextAlign.Center, 1)); + } + + rows.Add(new LayoutGridRow(IsHeader: false, cells)); + } + + var grid = new LayoutGrid(theme.LabelPadding * 2.0, theme.LabelPadding * 2.0, rows); + + var width = (theme.LabelPadding * 4.0) + headerWidth + (defs.Count * dataWidth); + var height = (theme.LabelPadding * 4.0) + ((defs.Count + 1) * rowHeight); + return new LayoutTree(width, height, [grid]); + } + + /// A user-defined definition with its supertype references. + private sealed record DefRow(string Name, IReadOnlyList SupertypeNames); + + /// Collects the non-stdlib definitions of the workspace in deterministic order. + private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace) + { + var result = new List(); + foreach (var qn in workspace.Declarations.Keys.OrderBy(k => k, StringComparer.Ordinal)) + { + if (StdlibFilter.IsStdlibElement(qn, workspace.StdlibNames)) + { + continue; + } + + if (workspace.Declarations[qn] is SysmlDefinitionNode def) + { + result.Add(new DefRow(def.Name ?? qn, def.SupertypeNames)); + } + } + + return result; + } + + /// Resolves a definition's supertype references to column indices by simple name. + private static HashSet ResolveSupertypeIndices(DefRow def, Dictionary indexByName) + { + var result = new HashSet(); + foreach (var supertype in def.SupertypeNames) + { + var sep = supertype.LastIndexOf("::", StringComparison.Ordinal); + var simple = sep >= 0 ? supertype[(sep + 2)..] : supertype; + if (indexByName.TryGetValue(simple, out var i)) + { + result.Add(i); + } + } + + return result; + } + + /// Computes the maximum rendered width of a set of labels at the given font size. + private static double MaxLabelWidth(IEnumerable labels, double fontSize) + { + var max = 0.0; + foreach (var label in labels) + { + max = Math.Max(max, label.Length * fontSize * CharWidthFactor); + } + + return Math.Max(40.0, max); + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs new file mode 100644 index 00000000..b26cacc2 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs @@ -0,0 +1,403 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout.Engine; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Rendering.Internal; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Layout.Internal; + +/// +/// Layout strategy for Interconnection View diagrams. Shows the internal structure of a single part +/// definition: its nested part usages as boxes placed by the force-directed engine, ports on the box +/// boundaries assigned by , and connection usages routed as orthogonal +/// connector lines between the ports. +/// +internal sealed class InterconnectionViewLayoutStrategy : ILayoutStrategy +{ + /// Minimum width of a nested part box. + private const double MinPartWidth = 110.0; + + /// Approximate width-per-character factor relative to font size. + private const double CharWidthFactor = 0.62; + + /// Nominal spacing between adjacent part centres in the force layout. + private const double PartSpacing = 150.0; + + /// Clearance kept between routed connectors and part boxes. + private const double ConnectorClearance = 10.0; + + /// A nested part usage with its computed box size. + private sealed record PartItem(string Name, string Keyword, string? Typing, double Width, double Height); + + /// A resolved binary connection between two nested part indices. + private sealed record ConnPair(int A, int B); + + /// + public LayoutTree BuildLayout(ViewContext context, RenderOptions options) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + var theme = options.Theme; + + // Choose the part definition whose internals to show: the one with the most connections. + var root = FindRoot(context.Workspace); + if (root is null) + { + return new LayoutTree(200.0, 100.0, []); + } + + var parts = CollectParts(root, theme); + if (parts.Count == 0) + { + return new LayoutTree(200.0, 100.0, []); + } + + var partIndex = BuildPartIndex(parts); + var pairs = ResolveConnections(root, partIndex); + + // Place the part boxes with the force-directed engine using connections as springs. + var force = ForceDirectedEngine.Place( + [.. parts.Select(p => new ForceNode(p.Width, p.Height))], + [.. pairs.Select(c => new ForceEdge(c.A, c.B))], + spacing: PartSpacing, + padding: theme.LabelPadding * 4.0); + + // Offset the placed parts to sit below the container title area. + var titleArea = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true); + var offsetX = theme.LabelPadding * 2.0; + var offsetY = titleArea + (theme.LabelPadding * 2.0); + + var partRects = new Rect[parts.Count]; + for (var i = 0; i < parts.Count; i++) + { + var r = force.Rects[i]; + partRects[i] = new Rect(r.X + offsetX, r.Y + offsetY, r.Width, r.Height); + } + + var nodes = new List(); + + // Container box for the root part definition. + var containerWidth = force.Width + (offsetX * 2.0); + var containerHeight = offsetY + force.Height + (theme.LabelPadding * 2.0); + nodes.Add(new LayoutBox( + X: 0, + Y: 0, + Width: containerWidth, + Height: containerHeight, + Label: root.Name ?? "Interconnection", + Depth: 0, + Shape: BoxShape.Rectangle, + Compartments: [], + Children: [], + Keyword: string.IsNullOrEmpty(root.DefinitionKeyword) ? "part def" : root.DefinitionKeyword)); + + // Part usage boxes (rounded β€” they are usages). + for (var i = 0; i < parts.Count; i++) + { + nodes.Add(MakePartBox(parts[i], partRects[i])); + } + + // Ports and connectors. + var crossings = AddPortsAndConnectors(parts, partRects, pairs, nodes); + + var warnings = LayoutWarnings.ForCrossings(context.ViewName, crossings); + return new LayoutTree(containerWidth, containerHeight, nodes) { Warnings = warnings }; + } + + /// + /// Finds the part definition whose interior to render: the non-stdlib part def with the + /// most connection usages, falling back to the one with the most part usages. + /// + private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace) + { + SysmlDefinitionNode? best = null; + var bestConnections = -1; + var bestParts = -1; + + foreach (var (qualifiedName, node) in workspace.Declarations) + { + if (node is not SysmlDefinitionNode def || def.DefinitionKeyword != "part def") + { + continue; + } + + if (StdlibFilter.IsStdlibElement(qualifiedName, workspace.StdlibNames)) + { + continue; + } + + var connections = def.Children.OfType().Count(); + var partCount = def.Children.OfType().Count(f => f.FeatureKeyword == "part"); + + if (connections > bestConnections || (connections == bestConnections && partCount > bestParts)) + { + best = def; + bestConnections = connections; + bestParts = partCount; + } + } + + return best; + } + + /// Collects the nested part usages of the root definition, sized for rendering. + private static IReadOnlyList CollectParts(SysmlDefinitionNode root, Theme theme) + { + var result = new List(); + foreach (var feature in root.Children.OfType()) + { + if (feature.FeatureKeyword != "part") + { + continue; + } + + var name = feature.Name ?? feature.FeatureTyping ?? "part"; + var (width, height) = ComputePartSize(name, feature.FeatureTyping, theme); + result.Add(new PartItem(name, "part", feature.FeatureTyping, width, height)); + } + + return result; + } + + /// Builds a name β†’ index lookup for the nested parts. + private static Dictionary BuildPartIndex(IReadOnlyList parts) + { + var index = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < parts.Count; i++) + { + index.TryAdd(parts[i].Name, i); + } + + return index; + } + + /// + /// Resolves each binary connection's endpoints to nested-part indices by matching the first + /// segment of the dotted endpoint reference against the part names. + /// + private static IReadOnlyList ResolveConnections(SysmlDefinitionNode root, Dictionary partIndex) + { + var pairs = new List(); + foreach (var conn in root.Children.OfType()) + { + var a = ResolveEndpoint(conn.EndpointA, partIndex); + var b = ResolveEndpoint(conn.EndpointB, partIndex); + if (a >= 0 && b >= 0 && a != b) + { + pairs.Add(new ConnPair(a, b)); + } + } + + return pairs; + } + + /// Resolves a dotted endpoint reference to a part index via its first segment. + private static int ResolveEndpoint(string? reference, Dictionary partIndex) + { + if (string.IsNullOrEmpty(reference)) + { + return -1; + } + + var dot = reference.IndexOf('.', StringComparison.Ordinal); + var head = dot >= 0 ? reference[..dot] : reference; + return partIndex.TryGetValue(head, out var i) ? i : -1; + } + + /// Computes the intrinsic size of a nested part box (keyword + name : type lines). + private static (double Width, double Height) ComputePartSize(string name, string? typing, Theme theme) + { + var label = typing is { Length: > 0 } ? $"{name} : {typing}" : name; + var labelWidth = (label.Length * theme.FontSizeTitle * CharWidthFactor) + (2.0 * theme.LabelPadding); + var width = Math.Max(MinPartWidth, labelWidth); + var height = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true) + theme.LabelPadding; + return (width, height); + } + + /// Creates a rounded-rectangle part usage box at the given position. + private static LayoutBox MakePartBox(PartItem part, Rect rect) + { + var label = part.Typing is { Length: > 0 } ? $"{part.Name} : {part.Typing}" : part.Name; + return new LayoutBox( + X: rect.X, + Y: rect.Y, + Width: rect.Width, + Height: rect.Height, + Label: label, + Depth: 1, + Shape: BoxShape.RoundedRectangle, + Compartments: [], + Children: [], + Keyword: part.Keyword); + } + + /// + /// Assigns ports to each part box for its incident connections and routes a connector line for + /// each connection between the two ports, appending the port and line nodes to the output and + /// returning the number of connectors that had to cross a box. + /// + private static int AddPortsAndConnectors( + IReadOnlyList parts, + Rect[] partRects, + IReadOnlyList pairs, + List nodes) + { + // For each part, collect a port request per incident connection (toward the other part). + var requestsPerPart = new List[parts.Count]; + var connSlotPerPart = new List[parts.Count]; + for (var i = 0; i < parts.Count; i++) + { + requestsPerPart[i] = []; + connSlotPerPart[i] = []; + } + + for (var c = 0; c < pairs.Count; c++) + { + var (a, b) = (pairs[c].A, pairs[c].B); + requestsPerPart[a].Add(new PortRequest(partRects[a], Centre(partRects[b]))); + connSlotPerPart[a].Add(c); + requestsPerPart[b].Add(new PortRequest(partRects[b], Centre(partRects[a]))); + connSlotPerPart[b].Add(c); + } + + // Assign port placements per part and index them by connection; also count how many ports + // share each box side (used to decide where it is safe to align a connector). + var portByPartConn = new Dictionary<(int Part, int Conn), PortPlacement>(); + var sideCount = new Dictionary<(int Part, PortSide Side), int>(); + for (var i = 0; i < parts.Count; i++) + { + var placements = PortAssigner.Assign(requestsPerPart[i]); + for (var k = 0; k < placements.Count; k++) + { + portByPartConn[(i, connSlotPerPart[i][k])] = placements[k]; + var sideKey = (i, placements[k].Side); + sideCount[sideKey] = sideCount.GetValueOrDefault(sideKey) + 1; + } + } + + // Alignment pass: where a connection's two ports each sit alone on facing edges and the boxes + // overlap along the connector axis, snap both ports to a common coordinate so the connector + // is a single straight line instead of having a small jog. Boxes are not moved, so this can + // never introduce an overlap. + for (var c = 0; c < pairs.Count; c++) + { + AlignConnectorPorts(pairs[c], c, partRects, sideCount, portByPartConn); + } + + // Emit the (possibly aligned) port nodes. + foreach (var placement in portByPartConn.Values) + { + nodes.Add(new LayoutPort(placement.CentreX, placement.CentreY, placement.Side, null)); + } + + // Route a connector line for each connection between its two ports. + var crossings = 0; + for (var c = 0; c < pairs.Count; c++) + { + var (a, b) = (pairs[c].A, pairs[c].B); + if (!portByPartConn.TryGetValue((a, c), out var portA) || + !portByPartConn.TryGetValue((b, c), out var portB)) + { + continue; + } + + var obstacles = new List(); + for (var i = 0; i < parts.Count; i++) + { + if (i != a && i != b) + { + obstacles.Add(partRects[i]); + } + } + + var route = ChannelRouter.RouteWithStatus( + new Point2D(portA.CentreX, portA.CentreY), + new Point2D(portB.CentreX, portB.CentreY), + obstacles, + ConnectorClearance, + sourceSide: portA.Side, + targetSide: portB.Side); + if (route.Crossed) + { + crossings++; + } + + nodes.Add(new LayoutLine( + Waypoints: route.Waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.None, + LineStyle: LineStyle.Solid, + MidpointLabel: null)); + } + + return crossings; + } + + /// + /// Snaps both ports of a connection to a shared axis coordinate so the connector renders as a + /// straight line, but only when each port is alone on its (facing) edge and the two boxes overlap + /// along the connector axis. In every other case the placement is left untouched. + /// + private static void AlignConnectorPorts( + ConnPair pair, + int conn, + Rect[] partRects, + Dictionary<(int Part, PortSide Side), int> sideCount, + Dictionary<(int Part, int Conn), PortPlacement> portByPartConn) + { + if (!portByPartConn.TryGetValue((pair.A, conn), out var portA) || + !portByPartConn.TryGetValue((pair.B, conn), out var portB)) + { + return; + } + + // Only safe when each port is the sole occupant of its edge. + if (sideCount.GetValueOrDefault((pair.A, portA.Side)) != 1 || + sideCount.GetValueOrDefault((pair.B, portB.Side)) != 1) + { + return; + } + + var boxA = partRects[pair.A]; + var boxB = partRects[pair.B]; + + var verticalFacing = + (portA.Side == PortSide.Top && portB.Side == PortSide.Bottom) || + (portA.Side == PortSide.Bottom && portB.Side == PortSide.Top); + var horizontalFacing = + (portA.Side == PortSide.Left && portB.Side == PortSide.Right) || + (portA.Side == PortSide.Right && portB.Side == PortSide.Left); + + if (verticalFacing) + { + var lo = Math.Max(boxA.X, boxB.X); + var hi = Math.Min(boxA.X + boxA.Width, boxB.X + boxB.Width); + if (lo <= hi) + { + var x = (lo + hi) / 2.0; + portByPartConn[(pair.A, conn)] = portA with { CentreX = x }; + portByPartConn[(pair.B, conn)] = portB with { CentreX = x }; + } + } + else if (horizontalFacing) + { + var lo = Math.Max(boxA.Y, boxB.Y); + var hi = Math.Min(boxA.Y + boxA.Height, boxB.Y + boxB.Height); + if (lo <= hi) + { + var y = (lo + hi) / 2.0; + portByPartConn[(pair.A, conn)] = portA with { CentreY = y }; + portByPartConn[(pair.B, conn)] = portB with { CentreY = y }; + } + } + } + + /// Returns the centre point of a rectangle. + private static Point2D Centre(Rect rect) => + new(rect.X + (rect.Width / 2.0), rect.Y + (rect.Height / 2.0)); +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs new file mode 100644 index 00000000..7785cd51 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs @@ -0,0 +1,36 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using System.Globalization; + +namespace DemaConsulting.SysML2Tools.Layout.Internal; + +/// +/// Builds the non-fatal layout-quality warning messages surfaced on a . +/// +internal static class LayoutWarnings +{ + /// + /// Returns a single-element warning list describing connectors that could not be routed without + /// crossing a box, or an empty list when is zero. + /// + /// Name of the view being laid out. + /// Number of connectors that had to cross a box. + /// The warning messages for the view. + public static IReadOnlyList ForCrossings(string viewName, int crossings) + { + if (crossings <= 0) + { + return []; + } + + var plural = crossings == 1 ? "connector" : "connectors"; + var count = crossings.ToString(CultureInfo.InvariantCulture); + return + [ + $"{count} {plural} in '{viewName}' could not be routed without crossing a box; " + + "the diagram may be cluttered.", + ]; + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs new file mode 100644 index 00000000..33b65514 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs @@ -0,0 +1,229 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Rendering.Internal; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Layout.Internal; + +/// +/// Layout strategy for Sequence View diagrams. Renders the participating lifelines as vertical +/// dashed stems with header boxes and draws each message as a horizontal arrow between lifelines, +/// ordered top-to-bottom by declaration order. +/// +/// +/// Lifelines are the distinct participants referenced by the messages' from/to events +/// (the first segment of each reference). Layout is pure arithmetic: lifeline X is the column index +/// times a pitch, and message Y is the message ordinal times a row pitch. +/// +internal sealed class SequenceViewLayoutStrategy : ILayoutStrategy +{ + /// Approximate width-per-character factor relative to font size. + private const double CharWidthFactor = 0.62; + + /// Minimum horizontal pitch between adjacent lifelines. + private const double MinPitch = 140.0; + + /// A message between two lifelines with an optional label. + private sealed record MessageItem(int From, int To, string Label); + + /// + public LayoutTree BuildLayout(ViewContext context, RenderOptions options) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + var theme = options.Theme; + + var root = FindRoot(context.Workspace); + if (root is null) + { + return new LayoutTree(200.0, 100.0, []); + } + + var (lifelines, index) = CollectLifelines(root); + var messages = ResolveMessages(root, index); + if (lifelines.Count == 0 || messages.Count == 0) + { + return new LayoutTree(200.0, 100.0, []); + } + + var headerHeight = theme.FontSizeTitle + (2.0 * theme.LabelPadding); + var pitch = ComputePitch(lifelines, theme); + var rowPitch = theme.FontSizeTitle * 2.5; + var margin = theme.LabelPadding * 3.0; + var headerWidth = pitch - (theme.LabelPadding * 4.0); + + var firstMessageY = margin + headerHeight + rowPitch; + var bottomY = firstMessageY + (messages.Count * rowPitch); + + var centreX = new double[lifelines.Count]; + for (var i = 0; i < lifelines.Count; i++) + { + centreX[i] = margin + (headerWidth / 2.0) + (i * pitch); + } + + var nodes = new List(); + + // Lifelines. + for (var i = 0; i < lifelines.Count; i++) + { + nodes.Add(new LayoutLifeline( + CentreX: centreX[i], + TopY: margin, + BottomY: bottomY, + Label: lifelines[i], + HeaderWidth: headerWidth, + HeaderHeight: headerHeight)); + } + + // Messages as horizontal arrows, ordered top-to-bottom. + for (var m = 0; m < messages.Count; m++) + { + var msg = messages[m]; + var y = firstMessageY + (m * rowPitch); + if (msg.From == msg.To) + { + nodes.Add(BuildSelfMessage(centreX[msg.From], y, theme, msg.Label)); + continue; + } + + nodes.Add(new LayoutLine( + Waypoints: [new Point2D(centreX[msg.From], y), new Point2D(centreX[msg.To], y)], + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Filled, + LineStyle: LineStyle.Solid, + MidpointLabel: msg.Label.Length > 0 ? msg.Label : null)); + } + + var width = margin + (lifelines.Count * pitch); + var height = bottomY + margin; + return new LayoutTree(width, height, nodes); + } + + /// Finds the definition with the most messages to use as the diagram root. + private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace) + { + SysmlDefinitionNode? best = null; + var bestMessages = 0; + + foreach (var (qualifiedName, node) in workspace.Declarations) + { + if (node is not SysmlDefinitionNode def) + { + continue; + } + + if (StdlibFilter.IsStdlibElement(qualifiedName, workspace.StdlibNames)) + { + continue; + } + + var messages = def.Children.OfType().Count(c => c.ConnectionKeyword == "message"); + if (messages > bestMessages) + { + best = def; + bestMessages = messages; + } + } + + return best; + } + + /// + /// Collects the lifelines participating in the root's messages β€” the distinct first segments of + /// the message from/to references β€” in first-appearance order. + /// + private static (IReadOnlyList Lifelines, Dictionary Index) CollectLifelines(SysmlDefinitionNode root) + { + var lifelines = new List(); + var index = new Dictionary(StringComparer.Ordinal); + + void Add(string? reference) + { + var name = FirstSegment(reference); + if (name is null || index.ContainsKey(name)) + { + return; + } + + index[name] = lifelines.Count; + lifelines.Add(name); + } + + foreach (var message in root.Children.OfType().Where(c => c.ConnectionKeyword == "message")) + { + Add(message.EndpointA); + Add(message.EndpointB); + } + + return (lifelines, index); + } + + /// Resolves the root's messages to lifeline indices, preserving declaration order. + private static IReadOnlyList ResolveMessages(SysmlDefinitionNode root, Dictionary index) + { + var result = new List(); + foreach (var message in root.Children.OfType().Where(c => c.ConnectionKeyword == "message")) + { + var from = FirstSegment(message.EndpointA); + var to = FirstSegment(message.EndpointB); + if (from is null || to is null || + !index.TryGetValue(from, out var fi) || !index.TryGetValue(to, out var ti)) + { + continue; + } + + result.Add(new MessageItem(fi, ti, message.Name ?? string.Empty)); + } + + return result; + } + + /// Computes the horizontal pitch between lifelines from the widest label. + private static double ComputePitch(IReadOnlyList lifelines, Theme theme) + { + var maxLabel = 0.0; + foreach (var lifeline in lifelines) + { + maxLabel = Math.Max(maxLabel, lifeline.Length * theme.FontSizeBody * CharWidthFactor); + } + + return Math.Max(MinPitch, maxLabel + (theme.LabelPadding * 8.0)); + } + + /// Builds a small self-message loop on a single lifeline. + private static LayoutLine BuildSelfMessage(double centreX, double y, Theme theme, string label) + { + var loop = theme.FontSizeTitle; + var waypoints = new List + { + new(centreX, y), + new(centreX + (loop * 1.5), y), + new(centreX + (loop * 1.5), y + loop), + new(centreX, y + loop), + }; + + return new LayoutLine( + Waypoints: waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Filled, + LineStyle: LineStyle.Solid, + MidpointLabel: label.Length > 0 ? label : null); + } + + /// Returns the first dot-separated segment of a reference, or null. + private static string? FirstSegment(string? reference) + { + if (string.IsNullOrEmpty(reference)) + { + return null; + } + + var dot = reference.IndexOf('.', StringComparison.Ordinal); + return dot >= 0 ? reference[..dot] : reference; + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs new file mode 100644 index 00000000..c8646fe2 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs @@ -0,0 +1,459 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout.Engine; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Rendering.Internal; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Layout.Internal; + +/// +/// Layout strategy for State Transition View diagrams. Renders state usages as rounded boxes placed +/// by the force-directed engine, an initial pseudo-state marker entering the first declared state, +/// and transitions as orthogonal arrows annotated with their guard conditions. +/// +/// +/// Transitions are routed with (orthogonal) rather than Bezier curves; +/// self-transitions are drawn as a small loop above the state. The initial state is taken to be the +/// first state declared in the owning definition. +/// +internal sealed class StateTransitionViewLayoutStrategy : ILayoutStrategy +{ + /// Minimum width of a state box. + private const double MinStateWidth = 100.0; + + /// Approximate width-per-character factor relative to font size. + private const double CharWidthFactor = 0.62; + + /// Nominal spacing between adjacent state centres in the force layout. + private const double StateSpacing = 240.0; + + /// Clearance kept between routed transitions and state boxes. + private const double TransitionClearance = 12.0; + + /// Diameter of the initial pseudo-state marker. + private const double InitialMarkerSize = 18.0; + + /// A state with its computed box size. + private sealed record StateItem(string Name, double Width, double Height); + + /// A resolved transition between two state indices with an optional guard. + private sealed record TransitionItem(int Source, int Target, string? Guard); + + /// + public LayoutTree BuildLayout(ViewContext context, RenderOptions options) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + var theme = options.Theme; + + var root = FindRoot(context.Workspace); + if (root is null) + { + return new LayoutTree(200.0, 100.0, []); + } + + var (states, index) = CollectStates(root, theme); + if (states.Count == 0) + { + return new LayoutTree(200.0, 100.0, []); + } + + var transitions = ResolveTransitions(root, index); + + // Place state boxes with the force-directed engine using transitions as springs. + var margin = theme.LabelPadding * 4.0; + var force = ForceDirectedEngine.Place( + [.. states.Select(s => new ForceNode(s.Width, s.Height))], + [.. transitions.Where(t => t.Source != t.Target).Select(t => new ForceEdge(t.Source, t.Target))], + spacing: StateSpacing, + padding: margin + InitialMarkerSize); + + var stateRects = new Rect[states.Count]; + for (var i = 0; i < states.Count; i++) + { + var r = force.Rects[i]; + stateRects[i] = new Rect(r.X, r.Y, r.Width, r.Height); + } + + var nodes = new List(); + + // State boxes (rounded rectangles). + for (var i = 0; i < states.Count; i++) + { + nodes.Add(MakeStateBox(states[i], stateRects[i])); + } + + // Initial pseudo-state entering the first declared state. + AddInitialMarker(stateRects[0], nodes); + + // Transition edges with guard labels. + var crossings = AddTransitions(transitions, stateRects, nodes); + + var warnings = LayoutWarnings.ForCrossings(context.ViewName, crossings); + return new LayoutTree(force.Width, force.Height, nodes) { Warnings = warnings }; + } + + /// Finds the definition with the most transitions to use as the diagram root. + private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace) + { + SysmlDefinitionNode? best = null; + var bestTransitions = -1; + + foreach (var (qualifiedName, node) in workspace.Declarations) + { + if (node is not SysmlDefinitionNode def) + { + continue; + } + + if (StdlibFilter.IsStdlibElement(qualifiedName, workspace.StdlibNames)) + { + continue; + } + + var transitions = def.Children.OfType().Count(); + if (transitions > bestTransitions) + { + best = def; + bestTransitions = transitions; + } + } + + return best; + } + + /// + /// Collects the states of the root definition β€” both declared state usages and any state names + /// referenced only by transitions β€” and builds a name β†’ index lookup. + /// + private static (IReadOnlyList States, Dictionary Index) CollectStates( + SysmlDefinitionNode root, + Theme theme) + { + var states = new List(); + var index = new Dictionary(StringComparer.Ordinal); + + void Add(string name) + { + if (index.ContainsKey(name)) + { + return; + } + + index[name] = states.Count; + var (width, height) = ComputeStateSize(name, theme); + states.Add(new StateItem(name, width, height)); + } + + // Declared state usages first (preserves declaration order for the initial-state choice). + foreach (var feature in root.Children.OfType()) + { + if (feature.FeatureKeyword == "state" && feature.Name is not null) + { + Add(feature.Name); + } + } + + // Any additional states referenced only by transition endpoints. + foreach (var transition in root.Children.OfType()) + { + if (LastSegment(transition.Source) is { } s) + { + Add(s); + } + + if (LastSegment(transition.Target) is { } t) + { + Add(t); + } + } + + return (states, index); + } + + /// Resolves transition endpoints to state indices via their last name segment. + private static IReadOnlyList ResolveTransitions(SysmlDefinitionNode root, Dictionary index) + { + var result = new List(); + foreach (var transition in root.Children.OfType()) + { + var source = LastSegment(transition.Source); + var target = LastSegment(transition.Target); + if (source is null || target is null || + !index.TryGetValue(source, out var si) || !index.TryGetValue(target, out var ti)) + { + continue; + } + + result.Add(new TransitionItem(si, ti, transition.Guard)); + } + + return result; + } + + /// Computes the intrinsic size of a state box. + private static (double Width, double Height) ComputeStateSize(string name, Theme theme) + { + var labelWidth = (name.Length * theme.FontSizeTitle * CharWidthFactor) + (4.0 * theme.LabelPadding); + var width = Math.Max(MinStateWidth, labelWidth); + var height = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true) + theme.LabelPadding; + return (width, height); + } + + /// Creates a rounded-rectangle state box at the given position. + private static LayoutBox MakeStateBox(StateItem state, Rect rect) => + new( + X: rect.X, + Y: rect.Y, + Width: rect.Width, + Height: rect.Height, + Label: state.Name, + Depth: 1, + Shape: BoxShape.RoundedRectangle, + Compartments: [], + Children: [], + Keyword: "state"); + + /// Adds the initial pseudo-state marker and its arrow into the first state. + private static void AddInitialMarker(Rect first, List nodes) + { + // Place the marker above the first state, centred horizontally. + var markerX = first.X + (first.Width / 2.0); + var markerY = first.Y - InitialMarkerSize - 10.0; + + nodes.Add(new LayoutBadge(markerX, markerY, InitialMarkerSize, BadgeShape.FilledCircle, null)); + + // Straight arrow from the marker down to the top of the first state. + nodes.Add(new LayoutLine( + Waypoints: [new Point2D(markerX, markerY + (InitialMarkerSize / 2.0)), new Point2D(markerX, first.Y)], + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Filled, + LineStyle: LineStyle.Solid, + MidpointLabel: null)); + } + + /// + /// Adds transition edges (with guard labels) between state boxes, returning the number of edges + /// that could not be routed without crossing a state box. + /// + /// + /// Each transition end attaches to the side of its box that faces the other state. When several + /// transitions share the same box side, their anchor points are distributed evenly along that + /// side (ordered to face their counterparts) instead of all stacking on the side midpoint, so an + /// incoming arrowhead never coincides with another transition's endpoint. + /// + private static int AddTransitions( + IReadOnlyList transitions, + Rect[] stateRects, + List nodes) + { + var count = transitions.Count; + var srcSide = new PortSide[count]; + var tgtSide = new PortSide[count]; + var srcPoint = new Point2D[count]; + var tgtPoint = new Point2D[count]; + + // Pass 1: determine the side each transition end attaches to. + for (var i = 0; i < count; i++) + { + var transition = transitions[i]; + if (transition.Source == transition.Target) + { + continue; + } + + srcSide[i] = SideToward(stateRects[transition.Source], Centre(stateRects[transition.Target])); + tgtSide[i] = SideToward(stateRects[transition.Target], Centre(stateRects[transition.Source])); + } + + // Pass 2: group endpoints by (state, side) and distribute them evenly along each side. + var groups = new Dictionary<(int State, PortSide Side), List<(int Trans, bool IsSource, double Order)>>(); + for (var i = 0; i < count; i++) + { + var transition = transitions[i]; + if (transition.Source == transition.Target) + { + continue; + } + + AddEndpoint(groups, transition.Source, srcSide[i], i, isSource: true, OrderKey(srcSide[i], Centre(stateRects[transition.Target]))); + AddEndpoint(groups, transition.Target, tgtSide[i], i, isSource: false, OrderKey(tgtSide[i], Centre(stateRects[transition.Source]))); + } + + foreach (var group in groups) + { + var ordered = group.Value.OrderBy(e => e.Order).ToList(); + + // Collapse runs of consecutive same-direction endpoints into shared anchor slots: this + // keeps inputs and outputs on separate points (so direction is never ambiguous) while + // reducing the number of distinct points on a busy edge. The crossing-minimizing order + // (by counterpart position) is preserved, so only adjacent same-direction edges merge. + var slots = new List<(bool IsSource, List Trans)>(); + foreach (var endpoint in ordered) + { + if (slots.Count == 0 || slots[^1].IsSource != endpoint.IsSource) + { + slots.Add((endpoint.IsSource, [])); + } + + slots[^1].Trans.Add(endpoint.Trans); + } + + for (var s = 0; s < slots.Count; s++) + { + var frac = (s + 1.0) / (slots.Count + 1.0); + var point = PointOnSide(stateRects[group.Key.State], group.Key.Side, frac); + foreach (var trans in slots[s].Trans) + { + if (slots[s].IsSource) + { + srcPoint[trans] = point; + } + else + { + tgtPoint[trans] = point; + } + } + } + } + + // Pass 3: route each transition and build its line. + var crossings = 0; + for (var i = 0; i < count; i++) + { + var transition = transitions[i]; + var label = transition.Guard is { Length: > 0 } g ? $"[{g}]" : null; + + if (transition.Source == transition.Target) + { + nodes.Add(BuildSelfLoop(stateRects[transition.Source], label)); + continue; + } + + var obstacles = new List(); + for (var j = 0; j < stateRects.Length; j++) + { + if (j != transition.Source && j != transition.Target) + { + obstacles.Add(stateRects[j]); + } + } + + var route = ChannelRouter.RouteWithStatus(srcPoint[i], tgtPoint[i], obstacles, TransitionClearance, srcSide[i], tgtSide[i]); + if (route.Crossed) + { + crossings++; + } + + nodes.Add(new LayoutLine( + Waypoints: route.Waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Filled, + LineStyle: LineStyle.Solid, + MidpointLabel: label)); + } + + return crossings; + } + + /// Registers a transition endpoint against the (state, side) group it attaches to. + private static void AddEndpoint( + Dictionary<(int State, PortSide Side), List<(int Trans, bool IsSource, double Order)>> groups, + int state, + PortSide side, + int trans, + bool isSource, + double order) + { + var key = (state, side); + if (!groups.TryGetValue(key, out var list)) + { + list = []; + groups[key] = list; + } + + list.Add((trans, isSource, order)); + } + + /// Builds a small self-transition loop above the state box. + private static LayoutLine BuildSelfLoop(Rect box, string? label) + { + const double Loop = 22.0; + var x1 = box.X + (box.Width * 0.35); + var x2 = box.X + (box.Width * 0.65); + var top = box.Y; + + var waypoints = new List + { + new(x1, top), + new(x1, top - Loop), + new(x2, top - Loop), + new(x2, top), + }; + + return new LayoutLine( + Waypoints: waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Filled, + LineStyle: LineStyle.Solid, + MidpointLabel: label); + } + + /// + /// Returns the side of the box whose outward normal best points at the target. + /// + private static PortSide SideToward(Rect box, Point2D target) + { + var cx = box.X + (box.Width / 2.0); + var cy = box.Y + (box.Height / 2.0); + var dx = target.X - cx; + var dy = target.Y - cy; + + if (Math.Abs(dx) >= Math.Abs(dy)) + { + return dx >= 0 ? PortSide.Right : PortSide.Left; + } + + return dy >= 0 ? PortSide.Bottom : PortSide.Top; + } + + /// + /// Returns the point at fractional position (0..1) along the given side + /// of the box. + /// + private static Point2D PointOnSide(Rect box, PortSide side, double frac) => side switch + { + PortSide.Top => new Point2D(box.X + (frac * box.Width), box.Y), + PortSide.Bottom => new Point2D(box.X + (frac * box.Width), box.Y + box.Height), + PortSide.Left => new Point2D(box.X, box.Y + (frac * box.Height)), + _ => new Point2D(box.X + box.Width, box.Y + (frac * box.Height)), + }; + + /// + /// Returns the ordering key used to lay endpoints out along a side so their connectors fan out + /// toward their counterparts without crossing: the counterpart coordinate along the side's axis. + /// + private static double OrderKey(PortSide side, Point2D counterpart) => + side is PortSide.Top or PortSide.Bottom ? counterpart.X : counterpart.Y; + + /// Returns the centre point of a rectangle. + private static Point2D Centre(Rect rect) => + new(rect.X + (rect.Width / 2.0), rect.Y + (rect.Height / 2.0)); + + /// Returns the last ::-separated segment of a qualified reference, or null. + private static string? LastSegment(string? reference) + { + if (string.IsNullOrEmpty(reference)) + { + return null; + } + + var sep = reference.LastIndexOf("::", StringComparison.Ordinal); + return sep >= 0 ? reference[(sep + 2)..] : reference; + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/LayoutBox.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/LayoutBox.cs index 5bc3da48..c68e271d 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/LayoutBox.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/LayoutBox.cs @@ -14,6 +14,12 @@ public enum BoxShape /// 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, } /// @@ -37,6 +43,11 @@ public sealed record LayoutCompartment( /// 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, @@ -46,4 +57,5 @@ public sealed record LayoutBox( int Depth, BoxShape Shape, IReadOnlyList Compartments, - IReadOnlyList Children) : LayoutNode; + IReadOnlyList Children, + string? Keyword = null) : LayoutNode; diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/LayoutTree.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/LayoutTree.cs index 58b6aab3..d6391d77 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/LayoutTree.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/LayoutTree.cs @@ -13,4 +13,11 @@ namespace DemaConsulting.SysML2Tools.Layout; public sealed record LayoutTree( double Width, double Height, - IReadOnlyList Nodes); + 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.SysML2Tools.Core/Rendering/DiagramRenderer.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs index ea99276e..ea092775 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs @@ -47,7 +47,7 @@ public static IReadOnlyList GetViewNames(SysmlWorkspace workspace) } // Skip stdlib view declarations β€” only user-defined views are considered - if (Internal.StdlibFilter.IsStdlibElement(qualifiedName)) + if (Internal.StdlibFilter.IsStdlibElement(qualifiedName, workspace.StdlibNames)) { continue; } @@ -110,7 +110,7 @@ public IReadOnlyList RenderWorkspace( } // Skip stdlib view declarations β€” only user-defined views are rendered - if (Internal.StdlibFilter.IsStdlibElement(qualifiedName)) + if (Internal.StdlibFilter.IsStdlibElement(qualifiedName, workspace.StdlibNames)) { continue; } @@ -144,7 +144,7 @@ public IReadOnlyList RenderWorkspace( // Derive a safe file name from the view name and add to results var fileName = SanitizeFileName(viewName) + renderer.DefaultExtension; - results.Add(new RenderOutput(fileName, renderer.MediaType, stream)); + results.Add(new RenderOutput(fileName, renderer.MediaType, stream) { Warnings = layout.Warnings }); } return results; @@ -185,7 +185,7 @@ public IReadOnlyList RenderWorkspace( foreach (var (qualifiedName, node) in workspace.Declarations) { // Skip stdlib elements β€” only user-defined declarations are considered - if (Internal.StdlibFilter.IsStdlibElement(qualifiedName)) + if (Internal.StdlibFilter.IsStdlibElement(qualifiedName, workspace.StdlibNames)) { continue; } diff --git a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs index 8e0f0967..8a43f000 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs @@ -4,6 +4,7 @@ using DemaConsulting.SysML2Tools.Layout.Internal; using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; namespace DemaConsulting.SysML2Tools.Rendering.Internal; @@ -12,9 +13,9 @@ namespace DemaConsulting.SysML2Tools.Rendering.Internal; /// based on the view type. /// /// -/// Phase 4 simplification: all view types are routed to . -/// Future phases will inspect the view's stereotype or keyword to select a specialized strategy -/// (e.g., IBD, sequence, activity). +/// Dispatch inspects the view's declared supertype names (and its own name) for a recognized view +/// kind. A view that specializes a name containing Interconnection routes to the +/// interconnection strategy; everything else falls back to the general view strategy. /// internal static class DiagramTypeRouter { @@ -36,11 +37,55 @@ public static ILayoutStrategy GetStrategy( SysmlWorkspace workspace, out string? unsupportedMessage) { - // Phase 4 simplification: route all view types to the general view strategy. - // Future phases will add stereotype inspection to select specialized strategies. - _ = viewNode; _ = workspace; unsupportedMessage = null; + + if (viewNode is SysmlViewNode view) + { + if (Matches(view, "Interconnection")) + { + return new InterconnectionViewLayoutStrategy(); + } + + if (Matches(view, "StateTransition") || Matches(view, "State")) + { + return new StateTransitionViewLayoutStrategy(); + } + + if (Matches(view, "ActionFlow") || Matches(view, "Action")) + { + return new ActionFlowViewLayoutStrategy(); + } + + if (Matches(view, "Grid") || Matches(view, "Matrix") || Matches(view, "Tabular")) + { + return new GridViewLayoutStrategy(); + } + + if (Matches(view, "Browser") || Matches(view, "Tree")) + { + return new BrowserViewLayoutStrategy(); + } + + if (Matches(view, "Sequence")) + { + return new SequenceViewLayoutStrategy(); + } + } + return new GeneralViewLayoutStrategy(); } + + /// + /// Determines whether a view declares the given view-kind marker in its name or a supertype. + /// + private static bool Matches(SysmlViewNode view, string marker) + { + if (view.Name is not null && view.Name.Contains(marker, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return view.SupertypeNames.Any(s => s.Contains(marker, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/StdlibFilter.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/StdlibFilter.cs index 2757c687..d18149ad 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/StdlibFilter.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/StdlibFilter.cs @@ -59,4 +59,15 @@ public static bool IsStdlibElement(string qualifiedName) => StdlibPrefixes.Any(prefix => qualifiedName == prefix || qualifiedName.StartsWith(prefix + "::", StringComparison.Ordinal)); + + /// + /// Returns when the qualified name is a standard-library element, + /// determined first by membership in the workspace's seed-derived + /// set and falling back to the root-package prefix heuristic. + /// + /// Fully-qualified element name to test. + /// Set of qualified names that originate from the stdlib seed. + /// if the element is part of the stdlib. + public static bool IsStdlibElement(string qualifiedName, IReadOnlySet stdlibNames) => + stdlibNames.Contains(qualifiedName) || IsStdlibElement(qualifiedName); } diff --git a/src/DemaConsulting.SysML2Tools.Core/Rendering/RenderOutput.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/RenderOutput.cs index 30e13159..61e83065 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/RenderOutput.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/RenderOutput.cs @@ -13,4 +13,11 @@ namespace DemaConsulting.SysML2Tools.Rendering; public sealed record RenderOutput( string SuggestedFileName, string MediaType, - Stream Data); + 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.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs index 3b1abd78..068923bc 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs @@ -113,6 +113,475 @@ private string QualifyName(string name) return BuildDefinitionNode(context.definition(), "item def"); } + /// + public override SysmlNode? VisitPortDefinition(SysMLv2Parser.PortDefinitionContext context) + { + return BuildDefinitionNode(context.definition(), "port def"); + } + + /// + public override SysmlNode? VisitConnectionDefinition(SysMLv2Parser.ConnectionDefinitionContext context) + { + return BuildDefinitionNode(context.definition(), "connection def"); + } + + /// + public override SysmlNode? VisitAllocationDefinition(SysMLv2Parser.AllocationDefinitionContext context) + { + return BuildDefinitionNode(context.definition(), "allocation def"); + } + + /// + public override SysmlNode? VisitFlowDefinition(SysMLv2Parser.FlowDefinitionContext context) + { + return BuildDefinitionNode(context.definition(), "flow def"); + } + + /// + public override SysmlNode? VisitOccurrenceDefinition(SysMLv2Parser.OccurrenceDefinitionContext context) + { + return BuildDefinitionNode(context.definition(), "occurrence def"); + } + + /// + public override SysmlNode? VisitIndividualDefinition(SysMLv2Parser.IndividualDefinitionContext context) + { + return BuildDefinitionNode(context.definition(), "individual def"); + } + + /// + public override SysmlNode? VisitRenderingDefinition(SysMLv2Parser.RenderingDefinitionContext context) + { + return BuildDefinitionNode(context.definition(), "rendering def"); + } + + /// + public override SysmlNode? VisitMetadataDefinition(SysMLv2Parser.MetadataDefinitionContext context) + { + return BuildDefinitionNode(context.definition(), "metadata def"); + } + + /// + public override SysmlNode? VisitEnumerationDefinition(SysMLv2Parser.EnumerationDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "enum def"); + } + + /// + public override SysmlNode? VisitInterfaceDefinition(SysMLv2Parser.InterfaceDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "interface def"); + } + + /// + public override SysmlNode? VisitActionDefinition(SysMLv2Parser.ActionDefinitionContext context) + { + var decl = context.definitionDeclaration(); + var name = GetDeclaredName(decl?.identification()); + if (name is null) + { + return null; + } + + var qualifiedName = QualifyName(name); + var supertypeNames = GetSubclassificationSupertypes(decl?.subclassificationPart()); + + // Collect the action body (action usages and successions) as children. + _namespaceStack.Add(name); + var children = CollectChildren(context.actionBody()?.actionBodyItem() ?? []); + _namespaceStack.RemoveAt(_namespaceStack.Count - 1); + + return new SysmlDefinitionNode + { + Name = name, + QualifiedName = qualifiedName, + DefinitionKeyword = "action def", + SupertypeNames = supertypeNames, + Children = children, + }; + } + + /// + public override SysmlNode? VisitActionUsage(SysMLv2Parser.ActionUsageContext context) + { + var name = GetDeclaredName(context.actionUsageDeclaration()?.usageDeclaration()?.identification()); + if (name is null) + { + return null; + } + + return new SysmlFeatureNode + { + Name = name, + QualifiedName = QualifyName(name), + FeatureKeyword = "action", + }; + } + + /// + public override SysmlNode? VisitSuccessionAsUsage(SysMLv2Parser.SuccessionAsUsageContext context) + { + // A succession links two action ends: first then . + var ends = context.connectorEndMember(); + if (ends.Length < 2) + { + return null; + } + + var source = ConnectorEndReference(ends[0]); + var target = ConnectorEndReference(ends[1]); + if (source is null || target is null) + { + return null; + } + + return new SysmlTransitionNode + { + Source = source, + Target = target, + }; + } + + /// + public override SysmlNode? VisitStateDefinition(SysMLv2Parser.StateDefinitionContext context) + { + var decl = context.definitionDeclaration(); + var name = GetDeclaredName(decl?.identification()); + if (name is null) + { + return null; + } + + var qualifiedName = QualifyName(name); + var supertypeNames = GetSubclassificationSupertypes(decl?.subclassificationPart()); + + // Collect the state body (state usages and transitions) as children. + _namespaceStack.Add(name); + var children = CollectChildren(context.stateDefBody()?.stateBodyItem() ?? []); + _namespaceStack.RemoveAt(_namespaceStack.Count - 1); + + return new SysmlDefinitionNode + { + Name = name, + QualifiedName = qualifiedName, + DefinitionKeyword = "state def", + SupertypeNames = supertypeNames, + Children = children, + }; + } + + /// + public override SysmlNode? VisitCalculationDefinition(SysMLv2Parser.CalculationDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "calc def"); + } + + /// + public override SysmlNode? VisitConstraintDefinition(SysMLv2Parser.ConstraintDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "constraint def"); + } + + /// + public override SysmlNode? VisitRequirementDefinition(SysMLv2Parser.RequirementDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "requirement def"); + } + + /// + public override SysmlNode? VisitConcernDefinition(SysMLv2Parser.ConcernDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "concern def"); + } + + /// + public override SysmlNode? VisitCaseDefinition(SysMLv2Parser.CaseDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "case def"); + } + + /// + public override SysmlNode? VisitAnalysisCaseDefinition(SysMLv2Parser.AnalysisCaseDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "analysis def"); + } + + /// + public override SysmlNode? VisitVerificationCaseDefinition(SysMLv2Parser.VerificationCaseDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "verification def"); + } + + /// + public override SysmlNode? VisitUseCaseDefinition(SysMLv2Parser.UseCaseDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "use case def"); + } + + /// + public override SysmlNode? VisitPartUsage(SysMLv2Parser.PartUsageContext context) + { + return BuildUsageNode(context.usage(), "part"); + } + + /// + public override SysmlNode? VisitPortUsage(SysMLv2Parser.PortUsageContext context) + { + return BuildUsageNode(context.usage(), "port"); + } + + /// + public override SysmlNode? VisitAttributeUsage(SysMLv2Parser.AttributeUsageContext context) + { + return BuildUsageNode(context.usage(), "attribute"); + } + + /// + public override SysmlNode? VisitItemUsage(SysMLv2Parser.ItemUsageContext context) + { + return BuildUsageNode(context.usage(), "item"); + } + + /// + public override SysmlNode? VisitReferenceUsage(SysMLv2Parser.ReferenceUsageContext context) + { + return BuildUsageNode(context.usage(), "ref"); + } + + /// + public override SysmlNode? VisitEnumerationUsage(SysMLv2Parser.EnumerationUsageContext context) + { + return BuildUsageNode(context.usage(), "enum"); + } + + /// + public override SysmlNode? VisitOccurrenceUsage(SysMLv2Parser.OccurrenceUsageContext context) + { + return BuildUsageNode(context.usage(), "occurrence"); + } + + /// + public override SysmlNode? VisitConnectionUsage(SysMLv2Parser.ConnectionUsageContext context) + { + var name = GetDeclaredName(context.usageDeclaration()?.identification()); + var (endpointA, endpointB) = ExtractConnectorEnds(context.connectorPart()); + + return new SysmlConnectionNode + { + Name = name, + QualifiedName = name is not null ? QualifyName(name) : null, + ConnectionKeyword = "connection", + EndpointA = endpointA, + EndpointB = endpointB, + }; + } + + /// + public override SysmlNode? VisitMessage(SysMLv2Parser.MessageContext context) + { + var decl = context.messageDeclaration(); + var name = GetDeclaredName(decl?.usageDeclaration()?.identification()); + + // A message links two events: from to . + string? from = null; + string? to = null; + var events = decl?.messageEventMember(); + if (events is { Length: >= 2 }) + { + from = events[0].messageEvent()?.ownedReferenceSubsetting()?.GetText(); + to = events[1].messageEvent()?.ownedReferenceSubsetting()?.GetText(); + } + + return new SysmlConnectionNode + { + Name = name, + QualifiedName = name is not null ? QualifyName(name) : null, + ConnectionKeyword = "message", + EndpointA = from, + EndpointB = to, + }; + } + + /// + public override SysmlNode? VisitStateUsage(SysMLv2Parser.StateUsageContext context) + { + var name = GetDeclaredName(context.actionUsageDeclaration()?.usageDeclaration()?.identification()); + if (name is null) + { + return null; + } + + return new SysmlFeatureNode + { + Name = name, + QualifiedName = QualifyName(name), + FeatureKeyword = "state", + }; + } + + /// + public override SysmlNode? VisitTransitionUsage(SysMLv2Parser.TransitionUsageContext context) + { + var name = GetDeclaredName(context.usageDeclaration()?.identification()); + + // Source is the feature chain after FIRST; target is the connector end after THEN. + var source = context.featureChainMember()?.GetText(); + var target = ConnectorEndReference( + context.transitionSuccessionMember()?.transitionSuccession()?.connectorEndMember()); + var guard = context.guardExpressionMember()?.ownedExpression()?.GetText(); + + return new SysmlTransitionNode + { + Name = name, + QualifiedName = name is not null ? QualifyName(name) : null, + Source = source, + Target = target, + Guard = guard, + }; + } + + /// + /// Extracts the two endpoint references of a binary connector (the features either side of + /// connect … to …), or nulls when the connector is not a simple binary connection. + /// + private static (string? A, string? B) ExtractConnectorEnds(SysMLv2Parser.ConnectorPartContext? connectorPart) + { + var binary = connectorPart?.binaryConnectorPart(); + if (binary is null) + { + return (null, null); + } + + var ends = binary.connectorEndMember(); + if (ends.Length < 2) + { + return (null, null); + } + + return (ConnectorEndReference(ends[0]), ConnectorEndReference(ends[1])); + } + + /// Returns the qualified feature reference named by a connector end, or null. + private static string? ConnectorEndReference(SysMLv2Parser.ConnectorEndMemberContext? member) + { + var end = member?.connectorEnd(); + var reference = end?.ownedReferenceSubsetting(); + return reference?.GetText(); + } + + /// + /// Builds a usage/feature AST node from a , capturing + /// the keyword, declared name, feature typing, multiplicity, and any nested usage children. + /// + private SysmlFeatureNode? BuildUsageNode(SysMLv2Parser.UsageContext? usage, string keyword) + { + if (usage is null) + { + return null; + } + + var decl = usage.usageDeclaration(); + var name = GetDeclaredName(decl?.identification()); + var typing = ExtractFeatureTyping(decl?.featureSpecializationPart()); + var multiplicity = ExtractMultiplicity(decl?.featureSpecializationPart()); + + // Named usages contribute a namespace segment for any nested usages they own. + var qualifiedName = name is not null ? QualifyName(name) : null; + IReadOnlyList children = Array.Empty(); + var body = usage.usageCompletion()?.usageBody()?.definitionBody(); + if (body is not null) + { + if (name is not null) + { + _namespaceStack.Add(name); + } + + children = CollectDefinitionBodyItems(body.definitionBodyItem()); + + if (name is not null) + { + _namespaceStack.RemoveAt(_namespaceStack.Count - 1); + } + } + + return new SysmlFeatureNode + { + Name = name, + QualifiedName = qualifiedName, + FeatureKeyword = keyword, + FeatureTyping = typing, + Multiplicity = multiplicity, + Children = children, + }; + } + + /// + /// Extracts the first feature-typing qualified name from a feature specialization part + /// (the type that follows : or typed by), or null when the feature is untyped. + /// + private static string? ExtractFeatureTyping(SysMLv2Parser.FeatureSpecializationPartContext? fsp) + { + if (fsp is null) + { + return null; + } + + foreach (var fs in fsp.featureSpecialization()) + { + var typings = fs.typings(); + if (typings is null) + { + continue; + } + + // The first typing is held by the typedBy clause; additional typings follow as a list. + var fromTypedBy = TypingName(typings.typedBy()?.featureTyping()); + if (fromTypedBy is not null) + { + return fromTypedBy; + } + + foreach (var ft in typings.featureTyping()) + { + var name = TypingName(ft); + if (name is not null) + { + return name; + } + } + } + + return null; + } + + /// Extracts the qualified type name from a single feature-typing context. + private static string? TypingName(SysMLv2Parser.FeatureTypingContext? ft) + { + if (ft is null) + { + return null; + } + + var owned = ft.ownedFeatureTyping(); + if (owned is not null) + { + return owned.GetText(); + } + + return ft.qualifiedName()?.GetText(); + } + + /// + /// Extracts the multiplicity text (e.g. [4]) from a feature specialization part, + /// or null when no multiplicity is declared. + /// + private static string? ExtractMultiplicity(SysMLv2Parser.FeatureSpecializationPartContext? fsp) + { + var multiplicity = fsp?.multiplicityPart()?.ownedMultiplicity(); + var text = multiplicity?.GetText(); + return string.IsNullOrEmpty(text) ? null : text; + } + /// public override SysmlNode? VisitViewDefinition(SysMLv2Parser.ViewDefinitionContext context) { @@ -315,6 +784,38 @@ private IReadOnlyList CollectTypeBodyItems( } + /// + /// Builds a definition AST node from a bare + /// for definition kinds whose grammar rule uses a specialized body (e.g. action, state, + /// requirement, enum) rather than the generic definition rule. + /// + /// + /// Only the declared name and supertype names are captured. The specialized body contents + /// (nested usages and compartment members) are not yet collected; that is handled in a later + /// phase that adds usage and compartment rendering. + /// + private SysmlDefinitionNode? BuildDefinitionFromDeclaration( + SysMLv2Parser.DefinitionDeclarationContext? decl, + string keyword) + { + var name = GetDeclaredName(decl?.identification()); + if (name is null) + { + return null; + } + + var qualifiedName = QualifyName(name); + var supertypeNames = GetSubclassificationSupertypes(decl?.subclassificationPart()); + + return new SysmlDefinitionNode + { + Name = name, + QualifiedName = qualifiedName, + DefinitionKeyword = keyword, + SupertypeNames = supertypeNames, + }; + } + /// /// Builds a definition AST node from the given . /// @@ -414,6 +915,26 @@ private static IReadOnlyList GetSubclassificationSupertypes( return names[0].GetText(); } + /// + /// Collects child nodes by visiting an arbitrary sequence of parse-tree contexts, keeping + /// each non-null result. Used for specialized bodies (e.g. state bodies) whose item type + /// differs from the generic definition body item. + /// + private IReadOnlyList CollectChildren(IEnumerable items) + { + var result = new List(); + foreach (var item in items) + { + var node = Visit(item); + if (node is not null) + { + result.Add(node); + } + } + + return result; + } + /// /// Collects child nodes from an array of . /// diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs index f1b1cbff..b7870860 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs @@ -15,6 +15,8 @@ namespace DemaConsulting.SysML2Tools.Semantic.Internal; [JsonDerivedType(typeof(SysmlImportNode), "import")] [JsonDerivedType(typeof(SysmlViewNode), "view")] [JsonDerivedType(typeof(SysmlViewpointNode), "viewpoint")] +[JsonDerivedType(typeof(SysmlConnectionNode), "connection")] +[JsonDerivedType(typeof(SysmlTransitionNode), "transition")] public abstract class SysmlNode { /// @@ -66,6 +68,20 @@ public sealed class SysmlDefinitionNode : SysmlNode /// public sealed class SysmlFeatureNode : SysmlNode { + /// + /// Gets the usage keyword (e.g., "part", "port", "attribute", "ref"). + /// + public string FeatureKeyword { get; init; } = string.Empty; + + /// + /// Gets the feature typing reference (the type after :), or null when untyped. + /// + public string? FeatureTyping { get; init; } + + /// + /// Gets the multiplicity text (e.g., "[4]", "[0..*]"), or null when unspecified. + /// + public string? Multiplicity { get; init; } } /// @@ -97,3 +113,45 @@ public sealed class SysmlViewNode : SysmlNode public sealed class SysmlViewpointNode : SysmlNode { } + +/// +/// AST node representing a connection/binding usage between two endpoints. +/// +public sealed class SysmlConnectionNode : SysmlNode +{ + /// + /// Gets the connection keyword (e.g., "connection", "binding"). + /// + public string ConnectionKeyword { get; init; } = string.Empty; + + /// + /// Gets the first endpoint reference (e.g., "engine.fuelPort"), or null when unresolved. + /// + public string? EndpointA { get; init; } + + /// + /// Gets the second endpoint reference (e.g., "transmission.input"), or null when unresolved. + /// + public string? EndpointB { get; init; } +} + +/// +/// AST node representing a state transition (source state, target state, optional guard). +/// +public sealed class SysmlTransitionNode : SysmlNode +{ + /// + /// Gets the source state reference, or null when implied by the containing state. + /// + public string? Source { get; init; } + + /// + /// Gets the target state reference. + /// + public string? Target { get; init; } + + /// + /// Gets the guard expression text (the condition after if), or null when unguarded. + /// + public string? Guard { get; init; } +} diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/SysmlWorkspace.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/SysmlWorkspace.cs index 64620941..9acbc315 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/SysmlWorkspace.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/SysmlWorkspace.cs @@ -22,6 +22,16 @@ public sealed class SysmlWorkspace /// public IReadOnlyList Files { get; init; } = Array.Empty(); + /// + /// Gets the set of fully-qualified names that originate from the standard-library seed. + /// + /// + /// Populated by from the seed symbol table. Renderers use this + /// to exclude standard-library elements from user-facing diagrams, which is more robust than + /// matching against a fixed list of root-package name prefixes. + /// + public IReadOnlySet StdlibNames { get; init; } = new HashSet(StringComparer.Ordinal); + /// /// Gets the qualified-name registry mapping fully-qualified names to their declaration nodes. /// diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/WorkspaceLoader.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/WorkspaceLoader.cs index 9070a7a3..e9c1761a 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/WorkspaceLoader.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/WorkspaceLoader.cs @@ -62,6 +62,9 @@ public static async Task LoadAsync( { Files = loadedFiles, Declarations = symbolTable.Symbols, + StdlibNames = seedSymbolTable is not null + ? new HashSet(seedSymbolTable.Symbols.Keys, StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal), }; return new SysmlLoadResult(workspace, allDiagnostics); diff --git a/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs b/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs index 2b933a9e..665e77db 100644 --- a/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs @@ -189,6 +189,19 @@ public void Render(LayoutTree layout, RenderOptions options, Stream output) 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 as PNG and write to the output stream using var image = SKImage.FromBitmap(bitmap); using var data = image.Encode(SKEncodedImageFormat.Png, 100); @@ -258,6 +271,47 @@ private static void RenderNode(SKCanvas canvas, LayoutNode node, RenderOptions o /// 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; @@ -266,65 +320,139 @@ private static void RenderBox(SKCanvas canvas, LayoutBox box, RenderOptions opti var y = (float)(box.Y * scale); var rect = new SKRect(x, y, x + (float)(box.Width * scale), y + (float)(box.Height * scale)); - // Corner radius for RoundedRectangle: double the line corner radius for visual prominence - var cornerR = (float)(theme.LineCornerRadius * 2.0 * scale); - var isRounded = box.Shape == BoxShape.RoundedRectangle && cornerR > 0; + 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, + }; - // Fill the box with the theme color for this depth level - var fillHex = theme.DepthFillColors[box.Depth % theme.DepthFillColors.Count]; - using (var fillPaint = new SKPaint()) + switch (box.Shape) { - fillPaint.Color = SKColor.Parse(fillHex); - fillPaint.Style = SKPaintStyle.Fill; - if (isRounded) - { + 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)(theme.LineCornerRadius * 2.0 * scale); canvas.DrawRoundRect(rect, cornerR, cornerR, fillPaint); - } - else - { + canvas.DrawRoundRect(rect, cornerR, cornerR, strokePaint); + break; + + default: canvas.DrawRect(rect, fillPaint); - } + canvas.DrawRect(rect, strokePaint); + break; } + } - // Draw the box border - var strokeColor = SKColor.Parse(theme.StrokeColor); - using (var strokePaint = new SKPaint()) + /// + /// 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 * 0.45, Math.Max(60.0, (box.Label?.Length ?? 4) * theme.FontSizeBody * 0.55 + 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) * 0.25, 16.0); + + 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) { - strokePaint.Color = strokeColor; - strokePaint.Style = SKPaintStyle.Stroke; - strokePaint.StrokeWidth = (float)theme.StrokeWidth * scale; - if (isRounded) - { - canvas.DrawRoundRect(rect, cornerR, cornerR, strokePaint); - } - else - { - canvas.DrawRect(rect, strokePaint); - } + 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; } - // Draw the centered label in the title area if present + // 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 textX = (float)((box.X + box.Width / 2.0) * scale); var availableWidth = (float)((box.Width - 2 * theme.LabelPadding) * scale); textPaint.TextSize = FitFontSize(textPaint, box.Label, availableWidth, textPaint.TextSize); - var textY = (float)((box.Y + theme.LabelPadding + theme.FontSizeTitle) * scale); - canvas.DrawText(box.Label, textX, textY, textPaint); - } - - // 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); + var textY = (float)((cursorY + theme.FontSizeTitle) * scale); + canvas.DrawText(box.Label, centerX, textY, textPaint); } } @@ -347,10 +475,8 @@ private static void RenderBoxCompartments( var theme = options.Theme; var scale = (float)options.Scale; - // Compartments start below the label area (padding + font + padding when label present) - var labelAreaHeight = box.Label != null - ? theme.LabelPadding + theme.FontSizeTitle + theme.LabelPadding - : 0.0; + // 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) @@ -390,6 +516,9 @@ private static void RenderBoxCompartments( 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; } } @@ -490,10 +619,64 @@ private static void RenderLine(SKCanvas canvas, LayoutLine line, RenderOptions o new ArrowheadPaint(strokeColor, (float)theme.StrokeWidth * scale, scale)); } - // Draw the optional midpoint label with a white background for readability - if (line.MidpointLabel != null) + // 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. + } + + /// + /// 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) { - RenderLineMidpointLabel(canvas, line.Waypoints, line.MidpointLabel, theme, scale, strokeColor); + 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; + } } } @@ -652,21 +835,21 @@ private static (double Dx, double Dy) ComputeDirection( /// rectangle drawn first to ensure readability over the line stroke. /// /// Canvas to draw on. - /// Ordered waypoints of the line; must contain at least one entry. + /// 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, - IReadOnlyList waypoints, + double midX, + double midY, string label, Theme theme, float scale, SKColor strokeColor) { - // Compute the geometric midpoint of the waypoints list - var (midX, midY) = ComputeLineMidpoint(waypoints); var scaledX = (float)(midX * scale); var scaledY = (float)(midY * scale); @@ -693,28 +876,6 @@ private static void RenderLineMidpointLabel( canvas.DrawText(label, scaledX, scaledY, textPaint); } - /// - /// Computes the geometric midpoint of an ordered waypoint list. For an odd number of - /// waypoints the center element is returned; for an even count the average of the two - /// center elements is returned. - /// - /// Ordered waypoints; must contain at least one entry. - /// The (X, Y) coordinates of the midpoint in logical pixels. - private static (double X, double Y) ComputeLineMidpoint(IReadOnlyList waypoints) - { - var n = waypoints.Count; - if (n % 2 == 1) - { - // Odd: middle element is the exact midpoint - return (waypoints[n / 2].X, waypoints[n / 2].Y); - } - - // Even: average the two center elements - var lo = waypoints[n / 2 - 1]; - var hi = waypoints[n / 2]; - return ((lo.X + hi.X) / 2.0, (lo.Y + hi.Y) / 2.0); - } - /// /// Renders a as a text element at its absolute position. /// diff --git a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs index 3a9edc02..d8a8b936 100644 --- a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs @@ -84,12 +84,25 @@ public void Render(LayoutTree layout, RenderOptions options, Stream output) // Write defs section with all arrowhead markers WriteArrowheadDefs(sb, theme); - // Render all top-level nodes recursively + // 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(""); @@ -168,6 +181,13 @@ private static void WriteArrowheadDefs(StringBuilder sb, Theme theme) sb.AppendLine(); sb.AppendLine(MarkerClose); + // Auto-sizing white background for text drawn over lines (e.g. message and guard labels). + // The filter region defaults to the text bounding box; the small negative inset adds padding. + sb.AppendLine(""" """); + sb.AppendLine(""" """); + sb.AppendLine(""" """); + sb.AppendLine(" "); + sb.AppendLine(" "); } @@ -241,42 +261,172 @@ private static void RenderBox(StringBuilder sb, LayoutBox box, Theme theme, doub // 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; - // Add rx/ry for rounded rectangle; corner radius doubles the line radius for prominence - var cornerStr = box.Shape == BoxShape.RoundedRectangle && theme.LineCornerRadius > 0 - ? $" rx=\"{F(theme.LineCornerRadius * 2.0 * scale)}\" ry=\"{F(theme.LineCornerRadius * 2.0 * scale)}\"" - : string.Empty; + 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(theme.LineCornerRadius * 2.0 * scale)}\" ry=\"{F(theme.LineCornerRadius * 2.0 * 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 * 0.45, Math.Max(60.0, (box.Label?.Length ?? 4) * theme.FontSizeBody * 0.55 + 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(); + } - // Draw the centered label in the title area if present - if (box.Label != null) + /// + /// 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) * 0.25; + fold = Math.Min(fold, 16.0); + + 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 textX = (box.X + box.Width / 2.0) * scale; - var textY = (box.Y + theme.LabelPadding + theme.FontSizeTitle / 2.0) * scale; - var availableWidth = (box.Width - 2 * theme.LabelPadding) * scale; + var kwY = (cursorY + theme.FontSizeBody / 2.0) * scale; sb.Append(CultureInfo.InvariantCulture, - $""" {EscapeXml(box.Label)}"""); + $""" {EscapeXml("\u00AB" + box.Keyword + "\u00BB")}"""); sb.AppendLine(); + cursorY += theme.FontSizeBody + theme.LabelPadding; } - // Render compartments below the label area - if (box.Compartments.Count > 0) + // 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) { - RenderBoxCompartments(sb, box, theme, scale); + 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(); } + } - // Render children recursively - foreach (var child in box.Children) + /// + /// 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) { - RenderNode(sb, child, theme, scale); + return string.Empty; } + + return $""" textLength="{F(availableWidth * scale)}" lengthAdjust="spacingAndGlyphs" """.TrimEnd(); } /// @@ -289,10 +439,8 @@ private static void RenderBox(StringBuilder sb, LayoutBox box, Theme theme, doub /// Uniform scale factor. private static void RenderBoxCompartments(StringBuilder sb, LayoutBox box, Theme theme, double scale) { - // Compartments start below the label area (padding + font + padding when label present) - var labelAreaHeight = box.Label != null - ? theme.LabelPadding + theme.FontSizeTitle + theme.LabelPadding - : 0.0; + // 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) @@ -323,6 +471,9 @@ private static void RenderBoxCompartments(StringBuilder sb, LayoutBox box, Theme sb.AppendLine(); compartmentY += theme.LabelPadding + theme.FontSizeBody; } + + // Bottom gap so the last row clears the next compartment divider. + compartmentY += theme.LabelPadding; } } @@ -387,13 +538,65 @@ private static void RenderLine(StringBuilder sb, LayoutLine line, Theme theme, d $""" """); sb.AppendLine(); - // Draw the optional midpoint label as a centered text element - if (line.MidpointLabel != null) + // 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) { - var (midX, midY) = ComputeLineMidpoint(line.Waypoints); - sb.Append(CultureInfo.InvariantCulture, - $""" {EscapeXml(line.MidpointLabel)}"""); - sb.AppendLine(); + 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; + } } } @@ -499,26 +702,6 @@ private static string BuildLinePath( return sb.ToString(); } - /// - /// Computes the geometric midpoint of an ordered waypoint list. For an odd count the - /// center element is returned; for an even count the average of the two center elements - /// is returned. - /// - /// Ordered waypoints; must contain at least one entry. - /// The (X, Y) coordinates of the midpoint in logical pixels. - private static (double X, double Y) ComputeLineMidpoint(IReadOnlyList waypoints) - { - var n = waypoints.Count; - if (n % 2 == 1) - { - return (waypoints[n / 2].X, waypoints[n / 2].Y); - } - - var lo = waypoints[n / 2 - 1]; - var hi = waypoints[n / 2]; - return ((lo.X + hi.X) / 2.0, (lo.Y + hi.Y) / 2.0); - } - /// /// Renders a as an SVG <text> element with /// text-anchor derived from . @@ -539,12 +722,12 @@ private static void RenderLabel(StringBuilder sb, LayoutLabel label, Theme theme }; var fontWeight = label.Weight == FontWeight.Bold ? "bold" : "normal"; var fontStyle = label.Style == FontStyle.Italic ? "italic" : "normal"; - var textLengthAttr = label.MaxWidth > 0 - ? $""" textLength="{F(label.MaxWidth * scale)}" lengthAdjust="spacingAndGlyphs" """ - : " "; + + // 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)}"""); + $""" {EscapeXml(label.Text)}"""); sb.AppendLine(); } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs index 12c946b1..14784363 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs @@ -118,6 +118,12 @@ public static async Task RunAsync(Context context) context.WriteLine($" Writing {filePath}"); await using var fileStream = File.Create(filePath); await output.Data.CopyToAsync(fileStream).ConfigureAwait(false); + + // Surface any layout-quality warnings for this view. + foreach (var warning in output.Warnings) + { + context.WriteLine($" warning: {warning}"); + } } context.WriteLine($"Rendered {outputs.Count} view(s)."); diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs new file mode 100644 index 00000000..15009d1e --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs @@ -0,0 +1,108 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Layout.Internal; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Tests for . +/// +public sealed class ActionFlowViewLayoutStrategyTests +{ + /// + /// An action definition with actions and successions produces action boxes, a start marker + /// (filled circle), a done marker (bullseye), and flow lines. + /// + [Fact] + public void ActionFlowView_BuildLayout_ActionsAndSuccessions_ProducesBoxesMarkersAndFlows() + { + // Arrange: a chain a -> b -> c + var strategy = new ActionFlowViewLayoutStrategy(); + var process = new SysmlDefinitionNode + { + Name = "Process", + QualifiedName = "P::Process", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "a", QualifiedName = "P::Process::a", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "b", QualifiedName = "P::Process::b", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "c", QualifiedName = "P::Process::c", FeatureKeyword = "action" }, + new SysmlTransitionNode { Source = "a", Target = "b" }, + new SysmlTransitionNode { Source = "b", Target = "c" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Process"] = process } + }; + var context = new ViewContext("ActionFlow", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: three action boxes, a start circle, a done bullseye, and flow lines + Assert.Equal(3, layout.Nodes.OfType().Count(b => b.Keyword == "action")); + Assert.Contains(layout.Nodes.OfType(), b => b.Shape == BadgeShape.FilledCircle); + Assert.Contains(layout.Nodes.OfType(), b => b.Shape == BadgeShape.Bullseye); + Assert.True(layout.Nodes.OfType().Count() >= 2); + } + + /// + /// Successive actions are placed top-to-bottom: a target action sits below its source. + /// + [Fact] + public void ActionFlowView_BuildLayout_Successions_FlowTopToBottom() + { + // Arrange: a -> b + var strategy = new ActionFlowViewLayoutStrategy(); + var process = new SysmlDefinitionNode + { + Name = "P", + QualifiedName = "M::P", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "a", QualifiedName = "M::P::a", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "b", QualifiedName = "M::P::b", FeatureKeyword = "action" }, + new SysmlTransitionNode { Source = "a", Target = "b" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["M::P"] = process } + }; + var context = new ViewContext("ActionFlow", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: action "a" is positioned above action "b" + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "action").ToList(); + var a = boxes.First(b => b.Label == "a"); + var b = boxes.First(box => box.Label == "b"); + Assert.True(a.Y < b.Y, "Source action should be above its successor."); + } + + /// An empty workspace yields a minimal canvas. + [Fact] + public void ActionFlowView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var workspace = new SysmlWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + Assert.Empty(layout.Nodes); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs new file mode 100644 index 00000000..8cf2ef37 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs @@ -0,0 +1,89 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Layout.Internal; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Tests for and . +/// +public sealed class BrowserAndGridViewLayoutStrategyTests +{ + /// + /// The browser view indents nested elements more than their parents. + /// + [Fact] + public void BrowserView_BuildLayout_NestedElements_AreIndentedByDepth() + { + // Arrange: a package containing a nested package and a def + var strategy = new BrowserViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Cat"] = new SysmlPackageNode { Name = "Cat", QualifiedName = "Cat" }, + ["Cat::Comp"] = new SysmlPackageNode { Name = "Comp", QualifiedName = "Cat::Comp" }, + ["Cat::Comp::Engine"] = new SysmlDefinitionNode { Name = "Engine", QualifiedName = "Cat::Comp::Engine", DefinitionKeyword = "part def" } + } + }; + var context = new ViewContext("CatBrowserView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: the deeply nested Engine box has a larger X than the root package box + var boxes = layout.Nodes.OfType().ToList(); + var root = boxes.First(b => b.Label!.Contains("Cat")); + var engine = boxes.First(b => b.Label!.Contains("Engine")); + Assert.True(engine.X > root.X, "Nested element should be indented more than its ancestor."); + } + + /// + /// The grid view produces a relationship matrix with a header row and a mark where a row + /// definition specializes a column definition. + /// + [Fact] + public void GridView_BuildLayout_Specialization_ProducesMarkedMatrix() + { + // Arrange: Car specializes Vehicle + var strategy = new GridViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::Vehicle"] = new SysmlDefinitionNode { Name = "Vehicle", QualifiedName = "P::Vehicle", DefinitionKeyword = "part def" }, + ["P::Car"] = new SysmlDefinitionNode { Name = "Car", QualifiedName = "P::Car", DefinitionKeyword = "part def", SupertypeNames = ["Vehicle"] } + } + }; + var context = new ViewContext("SpecMatrixView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a grid with a header row exists and contains exactly one specialization mark + var grid = Assert.Single(layout.Nodes.OfType()); + Assert.True(grid.Rows[0].IsHeader); + var markCount = grid.Rows.SelectMany(r => r.Cells).Count(c => c.Text == "X"); + Assert.Equal(1, markCount); + } + + /// Both strategies return a minimal canvas for an empty workspace. + [Fact] + public void BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas() + { + var workspace = new SysmlWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + Assert.Empty(new BrowserViewLayoutStrategy().BuildLayout(context, options).Nodes); + Assert.Empty(new GridViewLayoutStrategy().BuildLayout(context, options).Nodes); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ConnectorLabelPlacerTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ConnectorLabelPlacerTests.cs new file mode 100644 index 00000000..9ac5b829 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ConnectorLabelPlacerTests.cs @@ -0,0 +1,77 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Tests for . +/// +public sealed class ConnectorLabelPlacerTests +{ + /// A line without a label is omitted from the result. + [Fact] + public void Place_LineWithoutLabel_IsOmitted() + { + var line = new LayoutLine( + [new Point2D(0, 0), new Point2D(100, 0)], + ArrowheadStyle.None, + ArrowheadStyle.Filled, + LineStyle.Solid, + MidpointLabel: null); + + var result = ConnectorLabelPlacer.Place([line], fontSize: 12); + + Assert.Empty(result); + } + + /// A single labelled line is placed at the midpoint of its longest segment. + [Fact] + public void Place_SingleLine_UsesLongestSegmentMidpoint() + { + // A short vertical stub then a long horizontal run: the label should land on the long run. + var line = new LayoutLine( + [new Point2D(0, 0), new Point2D(0, 10), new Point2D(200, 10)], + ArrowheadStyle.None, + ArrowheadStyle.Filled, + LineStyle.Solid, + MidpointLabel: "[guard]"); + + var result = ConnectorLabelPlacer.Place([line], fontSize: 12); + + 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() + { + // Two lines whose longest-segment midpoints are the same point. + var a = new LayoutLine( + [new Point2D(0, 0), new Point2D(200, 0)], + ArrowheadStyle.None, + ArrowheadStyle.Filled, + LineStyle.Solid, + MidpointLabel: "[atFloor]"); + var b = new LayoutLine( + [new Point2D(0, 0), new Point2D(200, 0)], + ArrowheadStyle.None, + ArrowheadStyle.Filled, + LineStyle.Solid, + MidpointLabel: "[timeout]"); + + var result = ConnectorLabelPlacer.Place([a, b], fontSize: 12); + + var posA = result[a]; + var posB = result[b]; + + // The first keeps the preferred midpoint; the second is nudged away vertically. + 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.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs new file mode 100644 index 00000000..c2ec9047 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs @@ -0,0 +1,293 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Layout.Engine; + +namespace DemaConsulting.SysML2Tools.Tests.Layout.Engine; + +/// +/// Tests for orthogonal edge routing. +/// +public sealed class ChannelRouterTests +{ + /// + /// 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 = ChannelRouter.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 = ChannelRouter.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 = ChannelRouter.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 = ChannelRouter.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 = ChannelRouter.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 = ChannelRouter.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 = ChannelRouter.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 = ChannelRouter.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 = ChannelRouter.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 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 = ChannelRouter.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."); + } + } + + /// + /// 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.SysML2Tools.Tests/Layout/Engine/ContainmentPackerTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ContainmentPackerTests.cs new file mode 100644 index 00000000..61f910c3 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ContainmentPackerTests.cs @@ -0,0 +1,172 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout.Engine; + +namespace DemaConsulting.SysML2Tools.Tests.Layout.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.SysML2Tools.Tests/Layout/Engine/ForceDirectedEngineTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ForceDirectedEngineTests.cs new file mode 100644 index 00000000..2c99813d --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ForceDirectedEngineTests.cs @@ -0,0 +1,112 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout.Engine; + +namespace DemaConsulting.SysML2Tools.Tests.Layout.Engine; + +/// +/// Tests for spring layout. +/// +public sealed class ForceDirectedEngineTests +{ + /// An empty input yields a padding-only region with no rectangles. + [Fact] + public void Place_EmptyList_ReturnsPaddingOnlyRegion() + { + var result = ForceDirectedEngine.Place([], [], spacing: 80, padding: 10); + + Assert.Empty(result.Rects); + Assert.Equal(20.0, result.Width); + Assert.Equal(20.0, result.Height); + } + + /// A single node is placed at the padding origin. + [Fact] + public void Place_SingleNode_PositionsAtPadding() + { + var result = ForceDirectedEngine.Place([new ForceNode(40, 20)], [], spacing: 80, padding: 10); + + Assert.Single(result.Rects); + Assert.Equal(10.0, result.Rects[0].X, 6); + Assert.Equal(10.0, result.Rects[0].Y, 6); + } + + /// After convergence no two node bounding boxes overlap. + [Fact] + public void Place_ConnectedGraph_ProducesNoOverlaps() + { + // Arrange: a small graph with a hub connected to several leaves + var nodes = new[] + { + new ForceNode(60, 40), new ForceNode(60, 40), new ForceNode(60, 40), + new ForceNode(60, 40), new ForceNode(60, 40), new ForceNode(60, 40), + }; + var edges = new[] + { + new ForceEdge(0, 1), new ForceEdge(0, 2), new ForceEdge(0, 3), + new ForceEdge(0, 4), new ForceEdge(0, 5), new ForceEdge(1, 2), + }; + + // Act + var result = ForceDirectedEngine.Place(nodes, edges, spacing: 90, padding: 20); + + // 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."); + } + } + } + + /// All placed rectangles lie within the reported region bounds. + [Fact] + public void Place_ConnectedGraph_AllRectsWithinBounds() + { + var nodes = new[] + { + new ForceNode(50, 30), new ForceNode(50, 30), new ForceNode(50, 30), new ForceNode(50, 30), + }; + var edges = new[] { new ForceEdge(0, 1), new ForceEdge(1, 2), new ForceEdge(2, 3) }; + + var result = ForceDirectedEngine.Place(nodes, edges, spacing: 80, padding: 15); + + foreach (var r in result.Rects) + { + Assert.True(r.X >= -1e-6); + Assert.True(r.Y >= -1e-6); + Assert.True(r.X + r.Width <= result.Width + 1e-6); + Assert.True(r.Y + r.Height <= result.Height + 1e-6); + } + } + + /// The layout is deterministic: identical inputs yield identical outputs. + [Fact] + public void Place_SameInput_IsDeterministic() + { + var nodes = new[] { new ForceNode(50, 30), new ForceNode(50, 30), new ForceNode(50, 30) }; + var edges = new[] { new ForceEdge(0, 1), new ForceEdge(1, 2) }; + + var a = ForceDirectedEngine.Place(nodes, edges, spacing: 80, padding: 10); + var b = ForceDirectedEngine.Place(nodes, edges, spacing: 80, padding: 10); + + Assert.Equal(a.Width, b.Width, 9); + Assert.Equal(a.Height, b.Height, 9); + for (var i = 0; i < a.Rects.Count; i++) + { + Assert.Equal(a.Rects[i].X, b.Rects[i].X, 9); + Assert.Equal(a.Rects[i].Y, b.Rects[i].Y, 9); + } + } + + /// Determines whether two rectangles overlap with a positive-area intersection. + private static bool Overlaps(PackedRect a, PackedRect 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.SysML2Tools.Tests/Layout/Engine/LayeredLayoutEngineTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/LayeredLayoutEngineTests.cs new file mode 100644 index 00000000..931902b4 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/LayeredLayoutEngineTests.cs @@ -0,0 +1,118 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout.Engine; + +namespace DemaConsulting.SysML2Tools.Tests.Layout.Engine; + +/// +/// Tests for Sugiyama-style layered layout. +/// +public sealed class LayeredLayoutEngineTests +{ + /// An empty input yields a padding-only region. + [Fact] + public void Place_EmptyList_ReturnsPaddingOnlyRegion() + { + var result = LayeredLayoutEngine.Place([], [], layerGap: 40, nodeGap: 20, padding: 10); + + Assert.Empty(result.Rects); + Assert.Equal(20.0, result.Width); + Assert.Equal(20.0, result.Height); + } + + /// A simple chain assigns each node to a strictly increasing layer. + [Fact] + public void Place_Chain_AssignsIncreasingLayers() + { + // Arrange: a -> b -> c -> d + var nodes = Enumerable.Range(0, 4).Select(_ => new LayeredNode(60, 30)).ToList(); + var edges = new[] { new LayeredEdge(0, 1), new LayeredEdge(1, 2), new LayeredEdge(2, 3) }; + + // Act + var result = LayeredLayoutEngine.Place(nodes, edges, layerGap: 40, nodeGap: 20, padding: 10); + + // Assert: layers are 0,1,2,3 and Y increases with layer + Assert.Equal([0, 1, 2, 3], result.Layers); + Assert.True(result.Rects[0].Y < result.Rects[1].Y); + Assert.True(result.Rects[1].Y < result.Rects[2].Y); + Assert.True(result.Rects[2].Y < result.Rects[3].Y); + } + + /// Each edge points from a lower layer to a higher layer (top-to-bottom flow). + [Fact] + public void Place_Branching_EdgesPointDownward() + { + // Arrange: start -> {a, b} -> join + var nodes = Enumerable.Range(0, 4).Select(_ => new LayeredNode(60, 30)).ToList(); + var edges = new[] + { + new LayeredEdge(0, 1), new LayeredEdge(0, 2), + new LayeredEdge(1, 3), new LayeredEdge(2, 3), + }; + + // Act + var result = LayeredLayoutEngine.Place(nodes, edges, layerGap: 40, nodeGap: 20, padding: 10); + + // Assert: every edge has the source in a strictly smaller layer than the target + foreach (var e in edges) + { + Assert.True(result.Layers[e.From] < result.Layers[e.To], + $"Edge {e.From}->{e.To} does not point downward."); + } + } + + /// Nodes sharing a layer do not overlap horizontally. + [Fact] + public void Place_SameLayerNodes_DoNotOverlap() + { + // Arrange: a hub fanning out to four nodes on the same layer + var nodes = Enumerable.Range(0, 5).Select(_ => new LayeredNode(70, 30)).ToList(); + var edges = new[] + { + new LayeredEdge(0, 1), new LayeredEdge(0, 2), + new LayeredEdge(0, 3), new LayeredEdge(0, 4), + }; + + // Act + var result = LayeredLayoutEngine.Place(nodes, edges, layerGap: 40, nodeGap: 20, padding: 10); + + // Assert: the four layer-1 nodes do not overlap pairwise + for (var i = 1; i <= 4; i++) + { + for (var j = i + 1; j <= 4; j++) + { + Assert.False(Overlaps(result.Rects[i], result.Rects[j]), + $"Nodes {i} and {j} overlap."); + } + } + } + + /// A cycle is broken so layering terminates and produces a valid result. + [Fact] + public void Place_Cycle_TerminatesAndPlacesAllNodes() + { + // Arrange: a -> b -> c -> a (a cycle) + var nodes = Enumerable.Range(0, 3).Select(_ => new LayeredNode(60, 30)).ToList(); + var edges = new[] { new LayeredEdge(0, 1), new LayeredEdge(1, 2), new LayeredEdge(2, 0) }; + + // Act + var result = LayeredLayoutEngine.Place(nodes, edges, layerGap: 40, nodeGap: 20, padding: 10); + + // Assert: all nodes placed within bounds + Assert.Equal(3, result.Rects.Count); + foreach (var r in result.Rects) + { + Assert.True(r.X + r.Width <= result.Width + 1e-6); + Assert.True(r.Y + r.Height <= result.Height + 1e-6); + } + } + + /// Determines whether two rectangles overlap with a positive-area intersection. + private static bool Overlaps(PackedRect a, PackedRect 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.SysML2Tools.Tests/Layout/Engine/PortAssignerTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/PortAssignerTests.cs new file mode 100644 index 00000000..6b394f49 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/PortAssignerTests.cs @@ -0,0 +1,83 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Layout.Engine; + +namespace DemaConsulting.SysML2Tools.Tests.Layout.Engine; + +/// +/// Tests for port-side assignment and slot distribution. +/// +public sealed class PortAssignerTests +{ + /// A box at the origin; ports are assigned to the side facing their target. + [Theory] + [InlineData(500, 50, PortSide.Right)] + [InlineData(-500, 50, PortSide.Left)] + [InlineData(50, 500, PortSide.Bottom)] + [InlineData(50, -500, PortSide.Top)] + public void Assign_SinglePort_ChoosesSideFacingTarget(double towardX, double towardY, PortSide expected) + { + // Arrange: a 100x100 box at the origin with one port heading toward the target + var box = new Rect(0, 0, 100, 100); + var requests = new[] { new PortRequest(box, new Point2D(towardX, towardY)) }; + + // Act + var placements = PortAssigner.Assign(requests); + + // Assert: the port is on the expected side + Assert.Single(placements); + Assert.Equal(expected, placements[0].Side); + } + + /// A port's centre lies on the boundary of its assigned side. + [Fact] + public void Assign_Port_CentreLiesOnBoxBoundary() + { + var box = new Rect(10, 20, 100, 80); + var requests = new[] { new PortRequest(box, new Point2D(1000, 60)) }; + + var placements = PortAssigner.Assign(requests); + + // Right side: x == box right edge, y within the box vertical extent + Assert.Equal(PortSide.Right, placements[0].Side); + Assert.Equal(110.0, placements[0].CentreX, 6); + Assert.InRange(placements[0].CentreY, 20.0, 100.0); + } + + /// Multiple ports on the same side are distributed to distinct, evenly spaced slots. + [Fact] + public void Assign_MultiplePortsSameSide_AreEvenlyDistributed() + { + // Arrange: three ports all heading right, so all land on the right side + var box = new Rect(0, 0, 100, 120); + var requests = new[] + { + new PortRequest(box, new Point2D(500, 10)), + new PortRequest(box, new Point2D(500, 60)), + new PortRequest(box, new Point2D(500, 110)), + }; + + // Act + var placements = PortAssigner.Assign(requests); + + // Assert: all on the right side at distinct Y positions + Assert.All(placements, p => Assert.Equal(PortSide.Right, p.Side)); + var ys = placements.Select(p => p.CentreY).OrderBy(y => y).ToList(); + Assert.True(ys[0] < ys[1] && ys[1] < ys[2], "Ports should occupy distinct slots."); + + // Evenly spaced at 1/4, 2/4, 3/4 of the height + Assert.Equal(30.0, ys[0], 6); + Assert.Equal(60.0, ys[1], 6); + Assert.Equal(90.0, ys[2], 6); + } + + /// An empty request list yields no placements. + [Fact] + public void Assign_Empty_ReturnsEmpty() + { + Assert.Empty(PortAssigner.Assign([])); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs index 95cd2c27..4dfbe3e7 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs @@ -103,4 +103,186 @@ public void GeneralViewLayoutStrategy_BuildLayout_OneUserPartDef_ProducesLayoutB Assert.NotEmpty(layout.Nodes); Assert.Contains(layout.Nodes, n => n is LayoutBox); } + + /// + /// BuildLayout renders definitions of kinds other than part def (e.g. port def, + /// interface def), each carrying its keyword, confirming the strategy is no longer + /// restricted to part defs. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_MixedDefinitionKinds_RendersAllWithKeywords() + { + // Arrange: a workspace with three different definition kinds + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::Engine"] = new SysmlDefinitionNode { Name = "Engine", QualifiedName = "P::Engine", DefinitionKeyword = "part def" }, + ["P::FuelPort"] = new SysmlDefinitionNode { Name = "FuelPort", QualifiedName = "P::FuelPort", DefinitionKeyword = "port def" }, + ["P::IFuel"] = new SysmlDefinitionNode { Name = "IFuel", QualifiedName = "P::IFuel", DefinitionKeyword = "interface def" } + } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: collect every box keyword in the tree and confirm all three kinds appear + var keywords = CollectBoxes(layout.Nodes).Select(b => b.Keyword).ToList(); + Assert.Contains("part def", keywords); + Assert.Contains("port def", keywords); + Assert.Contains("interface def", keywords); + } + + /// + /// BuildLayout wraps a package's definitions in a folder-shaped container box. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_PackagedDefinitions_ProducesFolderBox() + { + // Arrange: two definitions within the same package + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Sys::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Sys::A", DefinitionKeyword = "part def" }, + ["Sys::B"] = new SysmlDefinitionNode { Name = "B", QualifiedName = "Sys::B", DefinitionKeyword = "part def" } + } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a folder-shaped box exists carrying the package keyword + var folder = CollectBoxes(layout.Nodes).FirstOrDefault(b => b.Shape == BoxShape.Folder); + Assert.NotNull(folder); + Assert.Equal("package", folder!.Keyword); + Assert.Equal("Sys", folder.Label); + } + + /// + /// BuildLayout draws a specialization edge (a ) between a subtype + /// and its supertype when both are present in the workspace. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_Subclassification_ProducesEdge() + { + // Arrange: B specializes A, both in the same package + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "P::A", DefinitionKeyword = "part def" }, + ["P::B"] = new SysmlDefinitionNode + { + Name = "B", + QualifiedName = "P::B", + DefinitionKeyword = "part def", + SupertypeNames = ["A"] + } + } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: at least one orthogonal line with an open arrowhead at the supertype end + var line = layout.Nodes.OfType().FirstOrDefault(); + Assert.NotNull(line); + Assert.Equal(ArrowheadStyle.Open, line!.TargetArrowhead); + Assert.True(line.Waypoints.Count >= 2); + } + + /// + /// BuildLayout excludes declarations listed in the workspace's seed-derived + /// set even when their names do not match a known + /// stdlib root-package prefix. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_SeedStdlibNames_AreExcluded() + { + // Arrange: a definition whose name is not a known stdlib prefix but is in the seed set + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["CustomLib::Helper"] = new SysmlDefinitionNode { Name = "Helper", QualifiedName = "CustomLib::Helper", DefinitionKeyword = "part def" } + }, + StdlibNames = new HashSet(StringComparer.Ordinal) { "CustomLib::Helper" } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: the seed-listed element is filtered out, producing the minimal canvas + Assert.Empty(layout.Nodes); + } + + /// + /// BuildLayout populates a definition box with compartments grouped by usage keyword, + /// formatting each usage as a name : Type row. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_DefinitionWithUsages_ProducesCompartments() + { + // Arrange: a part def owning an attribute usage and a port usage + var strategy = new GeneralViewLayoutStrategy(); + var vehicle = new SysmlDefinitionNode + { + Name = "Vehicle", + QualifiedName = "P::Vehicle", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "mass", QualifiedName = "P::Vehicle::mass", FeatureKeyword = "attribute", FeatureTyping = "Real" }, + new SysmlFeatureNode { Name = "fuel", QualifiedName = "P::Vehicle::fuel", FeatureKeyword = "port", FeatureTyping = "FuelPort" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Vehicle"] = vehicle } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: the Vehicle box has an attributes compartment and a ports compartment + var box = CollectBoxes(layout.Nodes).First(b => b.Label == "Vehicle"); + Assert.Equal(2, box.Compartments.Count); + Assert.Contains(box.Compartments, c => c.Title == "attributes" && c.Rows.Contains("mass : Real")); + Assert.Contains(box.Compartments, c => c.Title == "ports" && c.Rows.Contains("fuel : FuelPort")); + } + + /// Recursively collects all nodes from a node list. + private static IReadOnlyList CollectBoxes(IReadOnlyList nodes) + { + var result = new List(); + void Walk(IReadOnlyList ns) + { + foreach (var n in ns) + { + if (n is LayoutBox box) + { + result.Add(box); + Walk(box.Children); + } + } + } + + Walk(nodes); + return result; + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs new file mode 100644 index 00000000..62f8ebd0 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs @@ -0,0 +1,120 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Layout.Internal; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Tests for . +/// +public sealed class InterconnectionViewLayoutStrategyTests +{ + /// + /// A part definition with nested parts and connections renders as a container box with one + /// rounded part box per nested part, port nodes, and one connector line per connection. + /// + [Fact] + public void InterconnectionView_BuildLayout_PartsAndConnections_ProducesBoxesPortsAndLines() + { + // Arrange: a PowerSystem part def with two parts and one connection between them + var strategy = new InterconnectionViewLayoutStrategy(); + var powerSystem = new SysmlDefinitionNode + { + Name = "PowerSystem", + QualifiedName = "M::PowerSystem", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "engine", QualifiedName = "M::PowerSystem::engine", FeatureKeyword = "part", FeatureTyping = "Engine" }, + new SysmlFeatureNode { Name = "transmission", QualifiedName = "M::PowerSystem::transmission", FeatureKeyword = "part", FeatureTyping = "Transmission" }, + new SysmlConnectionNode { Name = "c1", QualifiedName = "M::PowerSystem::c1", ConnectionKeyword = "connection", EndpointA = "engine", EndpointB = "transmission" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["M::PowerSystem"] = powerSystem } + }; + var context = new ViewContext("PowerSystemInterconnectionView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: one container box, two part boxes, two ports (one per endpoint), one connector line + var boxes = layout.Nodes.OfType().ToList(); + Assert.Contains(boxes, b => b.Keyword == "part def" && b.Label == "PowerSystem"); + Assert.Equal(2, boxes.Count(b => b.Shape == BoxShape.RoundedRectangle)); + Assert.Equal(2, layout.Nodes.OfType().Count()); + Assert.Single(layout.Nodes.OfType()); + } + + /// + /// The two part boxes produced for connected parts do not overlap. + /// + [Fact] + public void InterconnectionView_BuildLayout_PartBoxes_DoNotOverlap() + { + // Arrange: three parts in a chain + var strategy = new InterconnectionViewLayoutStrategy(); + var root = new SysmlDefinitionNode + { + Name = "Sys", + QualifiedName = "M::Sys", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "a", QualifiedName = "M::Sys::a", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlFeatureNode { Name = "b", QualifiedName = "M::Sys::b", FeatureKeyword = "part", FeatureTyping = "B" }, + new SysmlFeatureNode { Name = "c", QualifiedName = "M::Sys::c", FeatureKeyword = "part", FeatureTyping = "C" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "a", EndpointB = "b" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "b", EndpointB = "c" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["M::Sys"] = root } + }; + var context = new ViewContext("Interconnection", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: no two rounded part boxes overlap + var partBoxes = layout.Nodes.OfType().Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); + for (var i = 0; i < partBoxes.Count; i++) + { + for (var j = i + 1; j < partBoxes.Count; j++) + { + Assert.False(Overlaps(partBoxes[i], partBoxes[j]), $"Part boxes {i} and {j} overlap."); + } + } + } + + /// An empty workspace yields a minimal canvas. + [Fact] + public void InterconnectionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = new SysmlWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + Assert.Empty(layout.Nodes); + } + + /// Determines whether two boxes overlap. + 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; +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs new file mode 100644 index 00000000..ace82911 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout.Internal; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Tests for . +/// +public sealed class LayoutWarningsTests +{ + /// Zero crossings produces no warnings. + [Fact] + public void ForCrossings_Zero_ReturnsEmpty() + { + Assert.Empty(LayoutWarnings.ForCrossings("View", 0)); + } + + /// A single crossing produces a singular-form warning naming the view. + [Fact] + public void ForCrossings_One_ReturnsSingularWarning() + { + var warnings = LayoutWarnings.ForCrossings("MyView", 1); + + var message = Assert.Single(warnings); + Assert.Contains("1 connector", message); + Assert.Contains("MyView", message); + } + + /// Multiple crossings produce a plural-form warning with the count. + [Fact] + public void ForCrossings_Many_ReturnsPluralWarning() + { + var warnings = LayoutWarnings.ForCrossings("V", 3); + + var message = Assert.Single(warnings); + Assert.Contains("3 connectors", message); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs new file mode 100644 index 00000000..084282c4 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs @@ -0,0 +1,109 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Layout.Internal; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Tests for . +/// +public sealed class SequenceViewLayoutStrategyTests +{ + /// + /// A definition with messages produces a lifeline per participant and a message line per + /// message, ordered top-to-bottom by declaration order. + /// + [Fact] + public void SequenceView_BuildLayout_Messages_ProducesLifelinesAndOrderedLines() + { + // Arrange: client/server with two messages + var strategy = new SequenceViewLayoutStrategy(); + var protocol = new SysmlDefinitionNode + { + Name = "Protocol", + QualifiedName = "P::Protocol", + DefinitionKeyword = "part def", + Children = + [ + new SysmlConnectionNode { Name = "request", ConnectionKeyword = "message", EndpointA = "client.a", EndpointB = "server.b" }, + new SysmlConnectionNode { Name = "response", ConnectionKeyword = "message", EndpointA = "server.c", EndpointB = "client.d" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Protocol"] = protocol } + }; + var context = new ViewContext("ProtocolSequenceView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: two lifelines (client, server) and two message lines + var lifelines = layout.Nodes.OfType().ToList(); + Assert.Equal(2, lifelines.Count); + Assert.Contains(lifelines, l => l.Label == "client"); + Assert.Contains(lifelines, l => l.Label == "server"); + + var lines = layout.Nodes.OfType().ToList(); + Assert.Equal(2, lines.Count); + + // The "request" line is above the "response" line (declaration order). + var request = lines.First(l => l.MidpointLabel == "request"); + var response = lines.First(l => l.MidpointLabel == "response"); + Assert.True(request.Waypoints[0].Y < response.Waypoints[0].Y); + } + + /// A message arrow runs horizontally from the sender lifeline to the receiver lifeline. + [Fact] + public void SequenceView_BuildLayout_Message_IsHorizontalBetweenLifelines() + { + // Arrange: a single message client -> server + var strategy = new SequenceViewLayoutStrategy(); + var protocol = new SysmlDefinitionNode + { + Name = "P", + QualifiedName = "M::P", + DefinitionKeyword = "part def", + Children = + [ + new SysmlConnectionNode { Name = "m", ConnectionKeyword = "message", EndpointA = "client.s", EndpointB = "server.r" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["M::P"] = protocol } + }; + var context = new ViewContext("Sequence", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: the message line is horizontal (equal Y) and has a filled arrowhead at the target + var line = Assert.Single(layout.Nodes.OfType()); + Assert.Equal(line.Waypoints[0].Y, line.Waypoints[^1].Y, 6); + Assert.NotEqual(line.Waypoints[0].X, line.Waypoints[^1].X); + Assert.Equal(ArrowheadStyle.Filled, line.TargetArrowhead); + } + + /// A workspace with no messages yields a minimal canvas. + [Fact] + public void SequenceView_BuildLayout_NoMessages_ReturnsMinimalCanvas() + { + var strategy = new SequenceViewLayoutStrategy(); + var workspace = new SysmlWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + Assert.Empty(layout.Nodes); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs new file mode 100644 index 00000000..2acf576f --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs @@ -0,0 +1,144 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Layout.Internal; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Tests for . +/// +public sealed class StateTransitionViewLayoutStrategyTests +{ + /// + /// A state definition with states and transitions produces a state box per state, an initial + /// pseudo-state badge, and a transition line carrying its guard label. + /// + [Fact] + public void StateTransitionView_BuildLayout_StatesAndTransitions_ProducesBoxesBadgeAndLines() + { + // Arrange: a Light state def with two states and a guarded transition + var strategy = new StateTransitionViewLayoutStrategy(); + var light = new SysmlDefinitionNode + { + Name = "Light", + QualifiedName = "SM::Light", + DefinitionKeyword = "state def", + Children = + [ + new SysmlFeatureNode { Name = "stop", QualifiedName = "SM::Light::stop", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "go", QualifiedName = "SM::Light::go", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "stop", Target = "go", Guard = "t" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["SM::Light"] = light } + }; + var context = new ViewContext("StateTransition", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: two state boxes, an initial badge, and a guard-labelled transition line + Assert.Equal(2, layout.Nodes.OfType().Count(b => b.Keyword == "state")); + Assert.Contains(layout.Nodes.OfType(), b => b.Shape == BadgeShape.FilledCircle); + Assert.Contains(layout.Nodes.OfType(), l => l.MidpointLabel == "[t]"); + } + + /// States referenced only by transitions are still created as boxes. + [Fact] + public void StateTransitionView_BuildLayout_UndeclaredStateInTransition_IsCreated() + { + // Arrange: only one declared state; the transition references an undeclared target + var strategy = new StateTransitionViewLayoutStrategy(); + var machine = new SysmlDefinitionNode + { + Name = "M", + QualifiedName = "P::M", + DefinitionKeyword = "state def", + Children = + [ + new SysmlFeatureNode { Name = "a", QualifiedName = "P::M::a", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "a", Target = "b", Guard = null } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::M"] = machine } + }; + var context = new ViewContext("StateTransition", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: two state boxes exist (a declared, b synthesized from the transition) + Assert.Equal(2, layout.Nodes.OfType().Count(b => b.Keyword == "state")); + } + + /// An empty workspace yields a minimal canvas. + [Fact] + public void StateTransitionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas() + { + var strategy = new StateTransitionViewLayoutStrategy(); + var workspace = new SysmlWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + Assert.Empty(layout.Nodes); + } + + /// + /// A state with both an outgoing and an incoming transition on the same edge anchors them at + /// distinct points so the two arrows do not coincide (which would hide their direction). + /// + [Fact] + public void StateTransitionView_BuildLayout_InAndOutOnSameEdge_UseDistinctAnchors() + { + // Arrange: two states with transitions in both directions (a->b and b->a). + var strategy = new StateTransitionViewLayoutStrategy(); + var machine = new SysmlDefinitionNode + { + Name = "M", + QualifiedName = "P::M", + DefinitionKeyword = "state def", + Children = + [ + new SysmlFeatureNode { Name = "a", QualifiedName = "P::M::a", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "b", QualifiedName = "P::M::b", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "a", Target = "b", Guard = "fwd" }, + new SysmlTransitionNode { Source = "b", Target = "a", Guard = "rev" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::M"] = machine } + }; + var context = new ViewContext("StateTransition", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // The forward line leaves state 'a' at its first waypoint; the reverse line enters state 'a' + // at its last waypoint. Both are on a's edge facing b, so they must be different points. + var lines = layout.Nodes.OfType().ToList(); + var forward = lines.Single(l => l.MidpointLabel == "[fwd]"); + var reverse = lines.Single(l => l.MidpointLabel == "[rev]"); + var outAnchor = forward.Waypoints[0]; + var inAnchor = reverse.Waypoints[^1]; + + Assert.False( + Math.Abs(outAnchor.X - inAnchor.X) < 1e-6 && Math.Abs(outAnchor.Y - inAnchor.Y) < 1e-6, + "Outgoing and incoming transitions on the same edge must not share an anchor point."); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs new file mode 100644 index 00000000..b6a965a4 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs @@ -0,0 +1,119 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout.Internal; +using DemaConsulting.SysML2Tools.Rendering.Internal; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Tests.Rendering; + +/// +/// Tests for view-kind dispatch. +/// +public sealed class DiagramTypeRouterTests +{ + /// A view whose name contains "Interconnection" routes to the interconnection strategy. + [Fact] + public void GetStrategy_InterconnectionNamedView_ReturnsInterconnectionStrategy() + { + var view = new SysmlViewNode { Name = "VehicleInterconnectionView", QualifiedName = "M::VehicleInterconnectionView" }; + var workspace = new SysmlWorkspace(); + + var strategy = DiagramTypeRouter.GetStrategy(view, workspace, out var unsupported); + + Assert.Null(unsupported); + Assert.IsType(strategy); + } + + /// A view specializing an interconnection view definition routes to that strategy. + [Fact] + public void GetStrategy_ViewSpecializingInterconnection_ReturnsInterconnectionStrategy() + { + var view = new SysmlViewNode + { + Name = "MyView", + QualifiedName = "M::MyView", + SupertypeNames = ["InterconnectionView"] + }; + var workspace = new SysmlWorkspace(); + + var strategy = DiagramTypeRouter.GetStrategy(view, workspace, out _); + + Assert.IsType(strategy); + } + + /// An ordinary view routes to the general view strategy. + [Fact] + public void GetStrategy_PlainView_ReturnsGeneralViewStrategy() + { + var view = new SysmlViewNode { Name = "GeneralView", QualifiedName = "M::GeneralView" }; + var workspace = new SysmlWorkspace(); + + var strategy = DiagramTypeRouter.GetStrategy(view, workspace, out var unsupported); + + Assert.Null(unsupported); + Assert.IsType(strategy); + } + + /// A view whose name contains "StateTransition" routes to the state-transition strategy. + [Fact] + public void GetStrategy_StateTransitionNamedView_ReturnsStateStrategy() + { + var view = new SysmlViewNode { Name = "TrafficStateTransitionView", QualifiedName = "M::TrafficStateTransitionView" }; + var workspace = new SysmlWorkspace(); + + var strategy = DiagramTypeRouter.GetStrategy(view, workspace, out _); + + Assert.IsType(strategy); + } + + /// A view whose name contains "ActionFlow" routes to the action-flow strategy. + [Fact] + public void GetStrategy_ActionFlowNamedView_ReturnsActionFlowStrategy() + { + var view = new SysmlViewNode { Name = "OrderActionFlowView", QualifiedName = "M::OrderActionFlowView" }; + var workspace = new SysmlWorkspace(); + + var strategy = DiagramTypeRouter.GetStrategy(view, workspace, out _); + + Assert.IsType(strategy); + } + + /// A view whose name contains "Matrix" routes to the grid strategy. + [Fact] + public void GetStrategy_MatrixNamedView_ReturnsGridStrategy() + { + var view = new SysmlViewNode { Name = "SpecializationMatrixView", QualifiedName = "M::SpecializationMatrixView" }; + var workspace = new SysmlWorkspace(); + + var strategy = DiagramTypeRouter.GetStrategy(view, workspace, out _); + + Assert.IsType(strategy); + } + + /// A view whose name contains "Browser" routes to the browser strategy. + [Fact] + public void GetStrategy_BrowserNamedView_ReturnsBrowserStrategy() + { + var view = new SysmlViewNode { Name = "CatalogBrowserView", QualifiedName = "M::CatalogBrowserView" }; + var workspace = new SysmlWorkspace(); + + var strategy = DiagramTypeRouter.GetStrategy(view, workspace, out _); + + Assert.IsType(strategy); + } + + /// A view whose name contains "Sequence" routes to the sequence strategy. + [Fact] + public void GetStrategy_SequenceNamedView_ReturnsSequenceStrategy() + { + var view = new SysmlViewNode { Name = "ProtocolSequenceView", QualifiedName = "M::ProtocolSequenceView" }; + var workspace = new SysmlWorkspace(); + + var strategy = DiagramTypeRouter.GetStrategy(view, workspace, out _); + + Assert.IsType(strategy); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index 27208aa2..6857122e 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -433,5 +433,320 @@ part def Foo specializes Bar {} File.Delete(tempFile); } } + + /// + /// A model declaring several definition kinds registers each with the correct definition + /// keyword, confirming the AST builder visits all definition rule variants. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_MixedDefinitionKinds_RegistersKeywords() + { + // Arrange: a package declaring part, port, interface, requirement, and enum definitions + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package Demo { + part def Vehicle; + port def FuelPort; + interface def FuelInterface; + requirement def MassReq; + enum def Gear; + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert: each definition is registered with its expected keyword + Assert.NotNull(result.Workspace); + AssertKeyword(result.Workspace!, "Demo::Vehicle", "part def"); + AssertKeyword(result.Workspace!, "Demo::FuelPort", "port def"); + AssertKeyword(result.Workspace!, "Demo::FuelInterface", "interface def"); + AssertKeyword(result.Workspace!, "Demo::MassReq", "requirement def"); + AssertKeyword(result.Workspace!, "Demo::Gear", "enum def"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Loading with a stdlib seed populates the workspace's + /// set with the seed's qualified names while excluding user declarations. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_PopulatesStdlibNamesFromSeed() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, "package UserPkg { part def UserPart; }", TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert: stdlib names are recorded and the user declaration is not among them + Assert.NotNull(result.Workspace); + Assert.NotEmpty(result.Workspace!.StdlibNames); + Assert.DoesNotContain("UserPkg::UserPart", result.Workspace.StdlibNames); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A definition owning usages registers them as feature children carrying the usage keyword, + /// declared name, and feature typing (including the type held by the typed by clause). + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_DefinitionUsages_CaptureKeywordAndTyping() + { + // Arrange: a part def owning an attribute, a port, and a multiplicity-bearing part usage + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package Demo { + part def Engine; + port def FuelPort; + part def Vehicle { + attribute mass : Real; + port fuelInlet : FuelPort; + part engine : Engine; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert: the Vehicle definition owns three feature children with the expected typing + Assert.NotNull(result.Workspace); + var vehicle = Assert.IsType( + result.Workspace!.Declarations["Demo::Vehicle"]); + var features = vehicle.Children + .OfType() + .ToList(); + + AssertFeature(features, "mass", "attribute", "Real"); + AssertFeature(features, "fuelInlet", "port", "FuelPort"); + AssertFeature(features, "engine", "part", "Engine"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A part definition with connection usages captures each connection's two endpoints. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_ConnectionUsages_CaptureEndpoints() + { + // Arrange: a part def with two parts and a connection between them + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package Demo { + part def Engine; + part def Gearbox; + part def Drivetrain { + part engine : Engine; + part gearbox : Gearbox; + connection link connect engine to gearbox; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert: the Drivetrain owns a connection node referencing both parts + Assert.NotNull(result.Workspace); + var drivetrain = Assert.IsType( + result.Workspace!.Declarations["Demo::Drivetrain"]); + var connection = drivetrain.Children + .OfType() + .Single(); + Assert.Equal("engine", connection.EndpointA); + Assert.Equal("gearbox", connection.EndpointB); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A state definition captures its declared state usages and transitions, recording each + /// transition's source, target, and guard. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_StateDefinition_CapturesStatesAndTransitions() + { + // Arrange: a state def with three states and guarded transitions + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package SM { + state def Light { + state stop; + state go; + transition first stop if t then go; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert: the state def owns two state features and one transition + Assert.NotNull(result.Workspace); + var light = Assert.IsType( + result.Workspace!.Declarations["SM::Light"]); + var states = light.Children + .OfType() + .Where(f => f.FeatureKeyword == "state") + .ToList(); + Assert.Equal(2, states.Count); + + var transition = light.Children + .OfType() + .Single(); + Assert.Equal("stop", transition.Source); + Assert.Equal("go", transition.Target); + Assert.Equal("t", transition.Guard); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// An action definition captures its action usages and successions (as transition nodes). + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_ActionDefinition_CapturesActionsAndSuccessions() + { + // Arrange: an action def with two actions and a succession between them + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package AF { + action def Flow { + action stepA; + action stepB; + first stepA then stepB; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert: the action def owns two action features and one succession + Assert.NotNull(result.Workspace); + var flow = Assert.IsType( + result.Workspace!.Declarations["AF::Flow"]); + var actions = flow.Children + .OfType() + .Count(f => f.FeatureKeyword == "action"); + Assert.Equal(2, actions); + + var succession = flow.Children + .OfType() + .Single(); + Assert.Equal("stepA", succession.Source); + Assert.Equal("stepB", succession.Target); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A definition with message usages captures each message's name and from/to endpoints. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_Messages_CaptureEndpoints() + { + // Arrange: a part def with two parts (each with an event) and a message between them + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package Seq { + part def Protocol { + part client { event occurrence s; } + part server { event occurrence r; } + message request from client.s to server.r; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert: the protocol owns a message connection with the expected endpoints + Assert.NotNull(result.Workspace); + var protocol = Assert.IsType( + result.Workspace!.Declarations["Seq::Protocol"]); + var message = protocol.Children + .OfType() + .Single(c => c.ConnectionKeyword == "message"); + Assert.Equal("request", message.Name); + Assert.Equal("client.s", message.EndpointA); + Assert.Equal("server.r", message.EndpointB); + } + finally + { + File.Delete(tempFile); + } + } + + /// Asserts that a feature with the given name has the expected keyword and typing. + private static void AssertFeature( + IEnumerable features, + string name, + string keyword, + string typing) + { + var feature = features.FirstOrDefault(f => f.Name == name); + Assert.NotNull(feature); + Assert.Equal(keyword, feature!.FeatureKeyword); + Assert.Equal(typing, feature.FeatureTyping); + } + + /// Asserts that the named declaration exists and is a definition with the given keyword. + private static void AssertKeyword(SysmlWorkspace workspace, string qualifiedName, string expectedKeyword) + { + Assert.True(workspace.Declarations.TryGetValue(qualifiedName, out var node), $"Missing {qualifiedName}"); + var def = Assert.IsType(node); + Assert.Equal(expectedKeyword, def.DefinitionKeyword); + } } diff --git a/test/SysMLModels/Custom/catalog-browser-grid.sysml b/test/SysMLModels/Custom/catalog-browser-grid.sysml new file mode 100644 index 00000000..19e16d9e --- /dev/null +++ b/test/SysMLModels/Custom/catalog-browser-grid.sysml @@ -0,0 +1,19 @@ +package Catalog { + + part def Vehicle; + part def Car :> Vehicle; + part def Truck :> Vehicle; + part def SportsCar :> Car; + + package Components { + part def Engine; + part def Wheel; + port def FuelPort; + } + + // "Browser" in the name selects the browser (tree) layout. + view def CatalogBrowserView {} + + // "Matrix" in the name selects the grid (relationship matrix) layout. + view def SpecializationMatrixView {} +} diff --git a/test/SysMLModels/Custom/client-server-sequence.sysml b/test/SysMLModels/Custom/client-server-sequence.sysml new file mode 100644 index 00000000..56154676 --- /dev/null +++ b/test/SysMLModels/Custom/client-server-sequence.sysml @@ -0,0 +1,21 @@ +package ClientServerProtocol { + + part def Protocol { + part client { + event occurrence sendRequest; + then event occurrence getResponse; + } + + part server { + event occurrence getRequest; + then event occurrence sendResponse; + } + + message request from client.sendRequest to server.getRequest; + message response from server.sendResponse to client.getResponse; + message acknowledge from client.sendRequest to server.getRequest; + } + + // "Sequence" in the name selects the sequence layout. + view def ProtocolSequenceView {} +} diff --git a/test/SysMLModels/Custom/order-action-flow.sysml b/test/SysMLModels/Custom/order-action-flow.sysml new file mode 100644 index 00000000..15683215 --- /dev/null +++ b/test/SysMLModels/Custom/order-action-flow.sysml @@ -0,0 +1,24 @@ +package OrderProcess { + + // An order-processing action flow with a branch and a join. + action def ProcessOrder { + action receiveOrder; + action validateOrder; + action checkStock; + action pickItems; + action backorder; + action packOrder; + action shipOrder; + + first receiveOrder then validateOrder; + first validateOrder then checkStock; + first checkStock then pickItems; + first checkStock then backorder; + first pickItems then packOrder; + first backorder then packOrder; + first packOrder then shipOrder; + } + + // The view name contains "ActionFlow" to select the action-flow layout. + view def OrderActionFlowView {} +} diff --git a/test/SysMLModels/Custom/power-system-interconnection.sysml b/test/SysMLModels/Custom/power-system-interconnection.sysml new file mode 100644 index 00000000..b4e14b37 --- /dev/null +++ b/test/SysMLModels/Custom/power-system-interconnection.sysml @@ -0,0 +1,27 @@ +package PowerSystemModel { + + part def Engine; + part def Transmission; + part def Driveshaft; + part def Differential; + part def Wheel; + + // PowerSystem decomposes into connected parts forming a drivetrain chain. + part def PowerSystem { + part engine : Engine; + part transmission : Transmission; + part driveshaft : Driveshaft; + part differential : Differential; + part wheelLeft : Wheel; + part wheelRight : Wheel; + + connection c1 connect engine to transmission; + connection c2 connect transmission to driveshaft; + connection c3 connect driveshaft to differential; + connection c4 connect differential to wheelLeft; + connection c5 connect differential to wheelRight; + } + + // The view name contains "Interconnection" to select the interconnection layout. + view def PowerSystemInterconnectionView {} +} diff --git a/test/SysMLModels/Custom/traffic-light-states.sysml b/test/SysMLModels/Custom/traffic-light-states.sysml new file mode 100644 index 00000000..1ffb0702 --- /dev/null +++ b/test/SysMLModels/Custom/traffic-light-states.sysml @@ -0,0 +1,16 @@ +package TrafficControl { + + // A three-state traffic-light cycle with guarded transitions. + state def TrafficLight { + state stop; + state go; + state caution; + + transition first stop if timerExpired then go; + transition first go if timerExpired then caution; + transition first caution if timerExpired then stop; + } + + // The view name contains "StateTransition" to select the state-transition layout. + view def TrafficStateTransitionView {} +} diff --git a/test/SysMLModels/Custom/vehicle-with-usages.sysml b/test/SysMLModels/Custom/vehicle-with-usages.sysml new file mode 100644 index 00000000..ed23bc82 --- /dev/null +++ b/test/SysMLModels/Custom/vehicle-with-usages.sysml @@ -0,0 +1,24 @@ +package VehicleModel { + + part def Engine; + + part def Wheel; + + port def FuelPort; + + // Vehicle aggregates several usages: attributes, a port, and nested parts. + part def Vehicle { + attribute mass : Real; + attribute maxSpeed : Real; + port fuelInlet : FuelPort; + part engine : Engine; + part wheels : Wheel[4]; + } + + // SportsCar specializes Vehicle to exercise a subclassification edge. + part def SportsCar :> Vehicle { + attribute topSpeed : Real; + } + + view def VehicleStructureView {} +}