From b8841f868ff6a3541c8257f10d9b1ea13e467fca Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 14:33:17 -0400 Subject: [PATCH 01/14] [Phase 6 complete] General View: all definition kinds, folder packages, edge routing Layout engines (Layout/Engine/): - ContainmentPacker: shelf bin-packing with area-based square-ish target width; 7 unit tests - ChannelRouter: Hanan-grid + A* orthogonal edge routing around obstacles; 4 unit tests Layout primitives: - LayoutBox gains optional Keyword (rendered as a smaller guillemet line above the name) and BoxShape.Folder / BoxShape.Note - BoxMetrics shared helper keeps renderer and layout title-area math consistent - SvgRenderer and PngRenderer render keyword lines, folder-tab packages, and note shapes Semantic model: - AstBuilder visits all definition kinds (port, interface, connection, requirement, action, state, enum, constraint, calc, case, allocation, flow, metadata, occurrence, individual, rendering, concern, analysis, verification, use case) with correct keywords - SysmlWorkspace.StdlibNames records seed-origin qualified names; WorkspaceLoader populates it - StdlibFilter gains a seed-aware overload so the entire standard library is filtered reliably (the fixed prefix list missed many function/datatype library packages) GeneralViewLayoutStrategy rewrite: - Renders every user-defined definition (not just part def) with its keyword - Groups definitions into folder-tab package containers via ContainmentPacker - Routes specialization edges with ChannelRouter so they avoid unrelated boxes - Preserves depth-limit ellipsis truncation indicator Visual gate passed (2a-PartsInterconnection, 1a-PartsTree, nested-packages). All tests green across net8/net9/net10; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 1 + ROADMAP.md | 12 +- .../Layout/BoxMetrics.cs | 53 ++ .../Layout/Engine/ChannelRouter.cs | 353 ++++++++++ .../Layout/Engine/ContainmentPacker.cs | 116 ++++ .../Internal/GeneralViewLayoutStrategy.cs | 609 +++++++++--------- .../Layout/LayoutBox.cs | 14 +- .../Rendering/DiagramRenderer.cs | 6 +- .../Rendering/Internal/StdlibFilter.cs | 11 + .../Semantic/Internal/AstBuilder.cs | 152 +++++ .../Semantic/SysmlWorkspace.cs | 10 + .../Semantic/WorkspaceLoader.cs | 3 + .../PngRenderer.cs | 209 ++++-- .../SvgRenderer.cs | 151 ++++- .../Layout/Engine/ChannelRouterTests.cs | 155 +++++ .../Layout/Engine/ContainmentPackerTests.cs | 172 +++++ .../Layout/GeneralViewLayoutStrategyTests.cs | 145 +++++ .../Semantic/WorkspaceLoaderTests.cs | 76 +++ 18 files changed, 1878 insertions(+), 370 deletions(-) create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/BoxMetrics.cs create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ContainmentPacker.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ContainmentPackerTests.cs diff --git a/.cspell.yaml b/.cspell.yaml index c1d0b72f..a613329a 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -30,6 +30,7 @@ words: - docversion - errorlevel - fileassert + - Hanan - KEBNF - MBSE - xunit diff --git a/ROADMAP.md b/ROADMAP.md index 32a70a05..434aeabf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -273,8 +273,8 @@ 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 | +| `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 | — | @@ -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 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/Engine/ChannelRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs new file mode 100644 index 00000000..c490c233 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs @@ -0,0 +1,353 @@ +// +// 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); + +/// +/// 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. + /// + /// 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) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(obstacles); + + // Build the candidate grid coordinates from endpoints and clearance-offset obstacle edges. + var xs = BuildAxis(source.X, target.X, obstacles, clearance, horizontal: true); + var ys = BuildAxis(source.Y, target.Y, obstacles, clearance, horizontal: false); + + var startI = IndexOf(xs, source.X); + var startJ = IndexOf(ys, source.Y); + var goalI = IndexOf(xs, target.X); + var goalJ = IndexOf(ys, target.Y); + + var path = AStar(xs, ys, startI, startJ, goalI, goalJ, obstacles); + if (path is null) + { + // No obstacle-free route found: fall back to an L-shaped path. + return BuildFallback(source, target); + } + + return Simplify(path); + } + + /// + /// 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) + { + 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 through an obstacle interior. + if (SegmentBlocked(xs, ys, ci, cj, ni, nj, obstacles)) + { + 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 through the + /// interior of any obstacle. + /// + private static bool SegmentBlocked( + double[] xs, + double[] ys, + int i1, + int j1, + int i2, + int j2, + IReadOnlyList obstacles) + { + 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 < y && y < r.Y + r.Height && + Math.Max(xa, r.X) < Math.Min(xb, r.X + r.Width)) + { + 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 < x && x < r.X + r.Width && + Math.Max(ya, r.Y) < Math.Min(yb, r.Y + r.Height)) + { + 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 the middle point when prev, cur, next are collinear (all share X or all share Y). + var collinearX = Math.Abs(prev.X - cur.X) < 1e-9 && Math.Abs(cur.X - next.X) < 1e-9; + var collinearY = Math.Abs(prev.Y - cur.Y) < 1e-9 && Math.Abs(cur.Y - next.Y) < 1e-9; + if (!collinearX && !collinearY) + { + result.Add(cur); + } + } + + result.Add(points[^1]); + return result; + } + + /// + /// Builds a simple L-shaped fallback route used when A* cannot find an obstacle-free path. + /// + private static IReadOnlyList BuildFallback(Point2D source, Point2D target) + { + // 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]; + } + + // Otherwise route horizontally then vertically through the elbow point. + return [source, new Point2D(target.X, source.Y), target]; + } +} 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/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index 570555cd..a2a1c67c 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,431 @@ 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, + 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 = BuildSpecializationEdges(defs, placed); + nodes.AddRange(edges); + + return new LayoutTree(canvasWidth, canvasHeight, nodes); } /// - /// 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; + + var (width, height) = ComputeBoxSize(simpleName, keyword, theme); + result.Add(new DefBox(qualifiedName, simpleName, keyword, def.SupertypeNames, width, height)); } return result; } + /// Computes the intrinsic box size needed to show a keyword line and a name line. + private static (double Width, double Height) ComputeBoxSize(string name, string keyword, 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)); + + // 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; + + return (width, height); + } + /// - /// Groups part-def entries by their parent package. + /// 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. /// - /// 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<(string Package, List Items)> GroupByPackage(IReadOnlyList defs) { - // Use ordered dictionary to preserve insertion order of groups - var groups = new Dictionary>(StringComparer.Ordinal); + var order = new List(); + var map = new Dictionary>(StringComparer.Ordinal); - foreach (var (qualifiedName, node) in partDefs) + foreach (var def in defs) { - // Extract parent package name from the qualified name - var lastSeparator = qualifiedName.LastIndexOf("::", StringComparison.Ordinal); - var packageName = lastSeparator >= 0 - ? qualifiedName[..lastSeparator] - : string.Empty; + var sep = def.QualifiedName.LastIndexOf("::", StringComparison.Ordinal); + var package = sep >= 0 ? def.QualifiedName[..sep] : string.Empty; - if (!groups.TryGetValue(packageName, out var group)) + if (!map.TryGetValue(package, out var list)) { - group = []; - groups[packageName] = group; + list = []; + map[package] = list; + order.Add(package); } - group.Add((qualifiedName, node)); + list.Add(def); } - return groups - .Select(kvp => (kvp.Key, (IReadOnlyList<(string, SysmlDefinitionNode)>)kvp.Value)) - .ToList(); + return [.. order.Select(p => (p, map[p]))]; } /// - /// Builds a two-column grid layout from the grouped part defs. + /// 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 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, + private static (List Nodes, List Placed, double Width, double Height) PlaceGroups( + IReadOnlyList<(string Package, List Items)> groups, Theme theme, int depthLimit) { - // 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++) + var margin = 2.0 * theme.LabelPadding; + var hGap = 3.0 * theme.LabelPadding; + var vGap = 2.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); + + // 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 (i % 2 == 0) + 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) { - col0Width = Math.Max(col0Width, groupWidths[i]); + // 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 { - col1Width = Math.Max(col1Width, groupWidths[i]); + // 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 }); } } - // Suppress unused variable warning when col1Width is never consumed in grid positioning - _ = col1Width; + // 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); - // 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; + var placed = new List(); - for (var i = 0; i < groups.Count; i++) + for (var i = 0; i < blocks.Count; i++) { - var (packageName, items) = groups[i]; - var gw = groupWidths[i]; - var gh = groupHeights[i]; + PlaceBlock(blocks[i], outer.Rects[i], folderTitleHeight, theme, nodes, placed); + } - double boxX; - double boxY; - if (i % 2 == 0) + return (nodes, placed, outer.Width, outer.Height); + } + + /// 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, + List nodes, + List placed) + { + 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; + } + + var children = new List(); + + if (block.Truncated) + { + // Show a visible truncation indicator instead of the hidden definition boxes. + children.Add(new LayoutLabel( + 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)); + } + else + { + // Folder containing packed definition boxes. + var inner = block.Inner!; + for (var k = 0; k < block.Items.Count; k++) { - // Left column - boxX = Margin; - boxY = cursorY0; - cursorY0 += gh + RowGap; + 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)); } - else + } + + 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: [], + Children: [], + Keyword: def.Keyword); + + /// Builds specialization (generalization) edges between placed definition boxes. + private static List BuildSpecializationEdges(IReadOnlyList defs, IReadOnlyList placed) + { + var edges = new List(); + + // 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 def in defs) + { + if (!byQualified.TryGetValue(def.QualifiedName, out var fromBox)) { - // Right column - boxX = cursorX1; - boxY = cursorY1; - cursorY1 += gh + RowGap; + continue; } - // 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); + foreach (var supertype in def.SupertypeNames) + { + if (!TryResolve(supertype, byQualified, bySimple, out var target) || + target!.QualifiedName == def.QualifiedName) + { + continue; + } - maxX = Math.Max(maxX, boxX + gw); - maxY = Math.Max(maxY, boxY + gh); + edges.Add(RouteEdge(fromBox, target, placed)); + } } - // Add specialization lines for part defs with supertypes - AddSpecializationLines(groups, nodes); - - var canvasWidth = maxX + Margin; - var canvasHeight = maxY + Margin; - return new LayoutTree(canvasWidth, canvasHeight, nodes); + return edges; } - /// - /// Computes the minimum width of a group box based on its label and child part defs. - /// - /// 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) + /// 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) { - // Start with the package label width - var labelWidth = packageName.Length * theme.FontSizeTitle * 0.6 + 2 * theme.LabelPadding; - var maxWidth = Math.Max(MinBoxWidth, labelWidth); + if (byQualified.TryGetValue(reference, out var q)) + { + target = q; + return true; + } - // Expand to fit each child label - foreach (var (qualifiedName, node) in items) + // 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)) { - var childLabel = node.Name ?? qualifiedName; - var childWidth = childLabel.Length * theme.FontSizeTitle * 0.6 + 4 * theme.LabelPadding; - maxWidth = Math.Max(maxWidth, Math.Max(MinBoxWidth, childWidth)); + target = s; + return true; } - return maxWidth + 2 * theme.LabelPadding; + target = null; + return false; } - /// - /// Computes the total height of a group box based on its title area and child box heights. - /// - /// 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) + /// Routes a single specialization edge from a subtype box to its supertype box. + private static LayoutLine RouteEdge(PlacedBox from, PlacedBox to, IReadOnlyList placed) { - // Title area height plus padding - var titleHeight = theme.FontSizeTitle + 2 * theme.LabelPadding; + 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)); - // Sum child box heights with vertical spacing - var childrenHeight = items.Count * MinBoxHeight + (items.Count + 1) * theme.LabelPadding; + var source = AnchorToward(from, toCenter); + var target = AnchorToward(to, fromCenter); - return titleHeight + childrenHeight; - } + // 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(); - /// - /// 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, - Theme theme, - int depthLimit) - { - var children = new List(); + var waypoints = ChannelRouter.Route(source, target, obstacles, EdgeClearance); - // 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; + // Generalization: open arrowhead points at the supertype (target) end. + return new LayoutLine( + Waypoints: waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Open, + LineStyle: LineStyle.Solid, + MidpointLabel: null); + } - // 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) - { - children.Add(new LayoutLabel( - X: childX, - Y: cursorY, - MaxWidth: childWidth, - Text: "…", - Align: TextAlign.Center, - Weight: FontWeight.Regular, - Style: FontStyle.Normal, - FontSize: theme.FontSizeTitle)); - return children; - } + /// Returns the midpoint of the box side whose outward normal best points at the target. + private static Point2D AnchorToward(PlacedBox 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; - foreach (var (qualifiedName, node) in items) + if (Math.Abs(dx) >= Math.Abs(dy)) { - 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; + // Left or right side. + return dx >= 0 + ? new Point2D(box.X + box.Width, cy) + : new Point2D(box.X, cy); } - return children; + // Top or bottom side. + return dy >= 0 + ? new Point2D(cx, box.Y + box.Height) + : new Point2D(cx, box.Y); } - /// - /// Adds nodes for specialization relationships between part defs - /// that declare supertypes. - /// - /// 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) + /// Computes the packing width used to lay out the definitions within a package folder. + private static double ComputePackWidth(IReadOnlyList items) { - // Build a lookup table from qualifiedName -> LayoutBox position - var boxPositions = BuildBoxPositionLookup(nodes); + // 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); + } - foreach (var (_, items) in groups) - { - foreach (var (qualifiedName, node) in items) - { - foreach (var supertypeName in node.SupertypeNames) - { - // 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)); - } - } - } + /// 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); } - /// - /// Builds a flat lookup dictionary from qualified name to by - /// walking the tree of top-level nodes recursively. - /// - /// Top-level nodes to search. - /// A dictionary mapping qualified name to the corresponding . - private static Dictionary BuildBoxPositionLookup(IReadOnlyList nodes) + /// Returns the last segment of a qualified package name for use as a folder label. + private static string SimplePackageName(string package) { - var result = new Dictionary(StringComparer.Ordinal); - CollectBoxes(nodes, result); - return result; + var sep = package.LastIndexOf("::", StringComparison.Ordinal); + return sep >= 0 ? package[(sep + 2)..] : package; } - /// - /// Recursively collects labeled nodes and their positions. - /// - /// Nodes to walk. - /// Dictionary to populate. - private static void CollectBoxes(IEnumerable nodes, Dictionary lookup) + /// 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) { - 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); - } + /// Packed inner layout of the folder's definition boxes, when this block is a folder. + public PackResult? Inner { get; init; } - // Recurse into children - CollectBoxes(box.Children, lookup); - } - } + /// 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/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/Rendering/DiagramRenderer.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs index ea99276e..19c9f172 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; } @@ -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/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.Language/Semantic/Internal/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs index 3b1abd78..8b67eb2c 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs @@ -113,6 +113,126 @@ 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) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "action def"); + } + + /// + public override SysmlNode? VisitStateDefinition(SysMLv2Parser.StateDefinitionContext context) + { + return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "state def"); + } + + /// + 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? VisitViewDefinition(SysMLv2Parser.ViewDefinitionContext context) { @@ -315,6 +435,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 . /// 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..243102da 100644 --- a/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs @@ -258,6 +258,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 +307,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 +462,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) diff --git a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs index 3a9edc02..a39d9f59 100644 --- a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs @@ -241,41 +241,146 @@ 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) - { - RenderBoxCompartments(sb, box, theme, scale); - } - - // Render children recursively - foreach (var child in box.Children) + // Bold name label + if (box.Label != null) { - RenderNode(sb, child, theme, scale); + var textY = (cursorY + theme.FontSizeTitle / 2.0) * scale; + var availableWidth = (box.Width - 2 * theme.LabelPadding) * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(box.Label)}"""); + sb.AppendLine(); } } @@ -289,10 +394,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) 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..b3c96eb8 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs @@ -0,0 +1,155 @@ +// +// 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)); + } + + /// + /// 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); + } +} 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/GeneralViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs index 95cd2c27..1364f6f5 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs @@ -103,4 +103,149 @@ 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); + } + + /// 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/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index 27208aa2..0660cce8 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -433,5 +433,81 @@ 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); + } + } + + /// 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); + } } From e91a6f978481e7f19befb9d9fa55afdc920184d4 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 14:57:29 -0400 Subject: [PATCH 02/14] [Phase 7 complete] General View: usages as typed compartments Semantic model: - SysmlFeatureNode gains FeatureKeyword, FeatureTyping, Multiplicity - AstBuilder visits part/port/attribute/item/reference/enum/occurrence usages, capturing keyword, declared name, feature typing (from both the typed-by clause and the typing list), multiplicity, and nested usage children Layout: - GeneralViewLayoutStrategy groups a definition's owned usages into keyword compartments (attributes, ports, parts, ...) with 'name : Type [n]' rows; box sizing accounts for compartment titles and rows Rendering: - SvgRenderer and PngRenderer add a bottom gap after each compartment's rows so the last row clears the next divider (readability fix) Design decision: General View renders usages as compartment rows (SysON General View style); nested-box containment with ports/connectors is the Interconnection View's role (Phase 8). Documentation/Comment note-shape nodes deferred (BoxShape.Note primitive ready). New test fixture test/SysMLModels/Custom/vehicle-with-usages.sysml. Visual gate passed. All tests green across net8/net9/net10; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ROADMAP.md | 15 +- .../Internal/GeneralViewLayoutStrategy.cs | 103 +++++++++++- .../Semantic/Internal/AstBuilder.cs | 155 ++++++++++++++++++ .../Semantic/Internal/SysmlNode.cs | 14 ++ .../PngRenderer.cs | 3 + .../SvgRenderer.cs | 3 + .../Layout/GeneralViewLayoutStrategyTests.cs | 37 +++++ .../Semantic/WorkspaceLoaderTests.cs | 59 +++++++ .../Custom/vehicle-with-usages.sysml | 24 +++ 9 files changed, 407 insertions(+), 6 deletions(-) create mode 100644 test/SysMLModels/Custom/vehicle-with-usages.sysml diff --git a/ROADMAP.md b/ROADMAP.md index 434aeabf..10b0bb4c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -353,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. diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index a2a1c67c..52007ab2 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -39,6 +39,7 @@ private sealed record DefBox( string SimpleName, string Keyword, IReadOnlyList SupertypeNames, + IReadOnlyList Compartments, double Width, double Height); @@ -96,26 +97,118 @@ private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace var simpleName = def.Name ?? qualifiedName; var keyword = string.IsNullOrEmpty(def.DefinitionKeyword) ? "def" : def.DefinitionKeyword; - var (width, height) = ComputeBoxSize(simpleName, keyword, theme); - result.Add(new DefBox(qualifiedName, simpleName, keyword, def.SupertypeNames, width, height)); + // 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; } - /// Computes the intrinsic box size needed to show a keyword line and a name line. - private static (double Width, double Height) ComputeBoxSize(string name, string keyword, Theme theme) + /// + /// Builds compartments for a definition by grouping its owned usage features by keyword and + /// formatting each as a name : Type [n] row. + /// + private static IReadOnlyList BuildCompartments(SysmlDefinitionNode def) + { + // Preserve keyword first-seen order so compartments appear in declaration order. + var order = new List(); + var groups = new Dictionary>(StringComparer.Ordinal); + + foreach (var child in def.Children) + { + if (child is not SysmlFeatureNode feature) + { + continue; + } + + var keyword = string.IsNullOrEmpty(feature.FeatureKeyword) ? "feature" : feature.FeatureKeyword; + if (!groups.TryGetValue(keyword, out var rows)) + { + rows = []; + groups[keyword] = rows; + order.Add(keyword); + } + + rows.Add(FormatFeatureRow(feature)); + } + + return [.. order.Select(k => new LayoutCompartment(Pluralize(k), groups[k]))]; + } + + /// 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 + { + "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 (compartment.Title is { } title) + { + width = Math.Max(width, (title.Length * theme.FontSizeBody * CharWidthFactor) + (2.0 * theme.LabelPadding)); + } + + foreach (var row in compartment.Rows) + { + width = Math.Max(width, (row.Length * theme.FontSizeBody * CharWidthFactor) + (3.0 * theme.LabelPadding)); + } + } + // 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) + { + height += ComputeCompartmentHeight(compartment, theme); + } return (width, height); } + /// + /// 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; + } + + height += compartment.Rows.Count * (theme.LabelPadding + theme.FontSizeBody); + + // Bottom gap added by the renderer after the last row. + height += theme.LabelPadding; + return height; + } + /// /// 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. @@ -288,7 +381,7 @@ private static LayoutBox MakeDefBox(DefBox def, double x, double y, int depth) = Label: def.SimpleName, Depth: depth, Shape: BoxShape.Rectangle, - Compartments: [], + Compartments: def.Compartments, Children: [], Keyword: def.Keyword); diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs index 8b67eb2c..aec4c5f3 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs @@ -233,6 +233,161 @@ private string QualifyName(string name) 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"); + } + + /// + /// 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) { diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs index f1b1cbff..9e9e7439 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs @@ -66,6 +66,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; } } /// diff --git a/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs b/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs index 243102da..2ef7f2c7 100644 --- a/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs @@ -503,6 +503,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; } } diff --git a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs index a39d9f59..b7e593e4 100644 --- a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs @@ -426,6 +426,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; } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs index 1364f6f5..4dfbe3e7 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs @@ -229,6 +229,43 @@ public void GeneralViewLayoutStrategy_BuildLayout_SeedStdlibNames_AreExcluded() 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) { diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index 0660cce8..1ca7a627 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -502,6 +502,65 @@ public async Task WorkspaceLoader_LoadAsync_PopulatesStdlibNamesFromSeed() } } + /// + /// 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); + } + } + + /// 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) { 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 {} +} From cc2542d6e9825714e9b93123403e8dd9e77d148d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 15:18:53 -0400 Subject: [PATCH 03/14] [Phase 8 complete] Interconnection View with force-directed parts and ports Layout engines (Layout/Engine/): - ForceDirectedEngine: deterministic Fruchterman-Reingold spring layout (golden-angle seed) with an overlap-removal pass; 5 unit tests - PortAssigner: assigns ports to the box side facing their target and distributes multiple ports evenly along each side, ordered to reduce crossings; 7 unit tests Semantic model: - SysmlConnectionNode captures connection usages with both endpoint references - AstBuilder.VisitConnectionUsage extracts the two connector ends of a binary connection Layout / dispatch: - InterconnectionViewLayoutStrategy renders a part definition's interior: nested part usages as rounded boxes (ForceDirectedEngine), ports on boundaries (PortAssigner), connection usages routed as orthogonal connectors (ChannelRouter), wrapped in a container box - DiagramTypeRouter dispatches to the interconnection strategy when a view's name or supertype contains 'Interconnection'; otherwise the general view strategy New fixture test/SysMLModels/Custom/power-system-interconnection.sysml. Visual gate passed (drivetrain chain, port-to-port connectors, no overlaps). All tests green net8/9/10; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 3 + ROADMAP.md | 16 +- .../Layout/Engine/ForceDirectedEngine.cs | 250 ++++++++++++++ .../Layout/Engine/PortAssigner.cs | 122 +++++++ .../InterconnectionViewLayoutStrategy.cs | 315 ++++++++++++++++++ .../Rendering/Internal/DiagramTypeRouter.cs | 31 +- .../Semantic/Internal/AstBuilder.cs | 45 +++ .../Semantic/Internal/SysmlNode.cs | 22 ++ .../Layout/Engine/ForceDirectedEngineTests.cs | 112 +++++++ .../Layout/Engine/PortAssignerTests.cs | 83 +++++ .../InterconnectionViewLayoutStrategyTests.cs | 120 +++++++ .../Rendering/DiagramTypeRouterTests.cs | 59 ++++ .../Semantic/WorkspaceLoaderTests.cs | 43 +++ .../Custom/power-system-interconnection.sysml | 27 ++ 14 files changed, 1239 insertions(+), 9 deletions(-) create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ForceDirectedEngine.cs create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Engine/PortAssigner.cs create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ForceDirectedEngineTests.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/PortAssignerTests.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs create mode 100644 test/SysMLModels/Custom/power-system-interconnection.sysml diff --git a/.cspell.yaml b/.cspell.yaml index a613329a..a4cf8bbd 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -30,6 +30,7 @@ words: - docversion - errorlevel - fileassert + - Fruchterman - Hanan - KEBNF - MBSE @@ -39,12 +40,14 @@ words: - Noto - Pandoc - Qube + - Reingold - reqstream - reviewmark - SARIF - sarifmark - setlocal - Skia + - Subsetting - SkiaSharp - snupkg - sonarmark diff --git a/ROADMAP.md b/ROADMAP.md index 10b0bb4c..1da6eacb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -275,8 +275,8 @@ Grid, Browser, Geometry) require no engine — pure arithmetic in the strategy c |--------|----------|---------------|-----------| | `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 | — | +| `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 @@ -414,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. 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/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/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs new file mode 100644 index 00000000..f61abe2f --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs @@ -0,0 +1,315 @@ +// +// 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. + AddPortsAndConnectors(parts, partRects, pairs, nodes); + + return new LayoutTree(containerWidth, containerHeight, nodes); + } + + /// + /// 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. + /// + private static void 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. + var portByPartConn = new Dictionary<(int Part, int Conn), PortPlacement>(); + 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]; + nodes.Add(new LayoutPort(placements[k].CentreX, placements[k].CentreY, placements[k].Side, null)); + } + } + + // Route a connector line for each connection between its two ports. + 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 waypoints = ChannelRouter.Route( + new Point2D(portA.CentreX, portA.CentreY), + new Point2D(portB.CentreX, portB.CentreY), + obstacles, + ConnectorClearance); + + nodes.Add(new LayoutLine( + Waypoints: waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.None, + LineStyle: LineStyle.Solid, + MidpointLabel: null)); + } + } + + /// 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/Rendering/Internal/DiagramTypeRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs index 8e0f0967..5ef5b99a 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,29 @@ 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 && IsInterconnectionView(view)) + { + return new InterconnectionViewLayoutStrategy(); + } + return new GeneralViewLayoutStrategy(); } + + /// + /// Determines whether a view declares itself as an interconnection view by specializing (or + /// being named after) a view kind whose name contains Interconnection. + /// + private static bool IsInterconnectionView(SysmlViewNode view) + { + const string Marker = "Interconnection"; + 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.Language/Semantic/Internal/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs index aec4c5f3..7b7ef40d 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs @@ -275,6 +275,51 @@ private string QualifyName(string name) 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, + }; + } + + /// + /// 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. diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs index 9e9e7439..b0408d5b 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs @@ -15,6 +15,7 @@ namespace DemaConsulting.SysML2Tools.Semantic.Internal; [JsonDerivedType(typeof(SysmlImportNode), "import")] [JsonDerivedType(typeof(SysmlViewNode), "view")] [JsonDerivedType(typeof(SysmlViewpointNode), "viewpoint")] +[JsonDerivedType(typeof(SysmlConnectionNode), "connection")] public abstract class SysmlNode { /// @@ -111,3 +112,24 @@ 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; } +} 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/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/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/Rendering/DiagramTypeRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs new file mode 100644 index 00000000..3cd9fbd9 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs @@ -0,0 +1,59 @@ +// +// 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); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index 1ca7a627..9d20b9ee 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -548,6 +548,49 @@ part def Vehicle { } } + /// + /// 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); + } + } + /// Asserts that a feature with the given name has the expected keyword and typing. private static void AssertFeature( IEnumerable features, 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 {} +} From 3477cebc96f5168bedeeafb7a8709a4541058caa Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 15:36:54 -0400 Subject: [PATCH 04/14] [Phase 9 complete] State Transition View with force-directed states Semantic model: - SysmlTransitionNode captures transition source, target, and guard - AstBuilder.VisitStateUsage (keyword 'state') and VisitTransitionUsage (source from featureChainMember, target from connector end, guard from IF expression) - VisitStateDefinition now collects the state-def body via a generic CollectChildren helper, fixing the gap where specialized-body definitions dropped their children Layout / dispatch: - StateTransitionViewLayoutStrategy places state usages as rounded boxes (ForceDirectedEngine), draws an initial pseudo-state (filled circle) into the first declared state, and renders transitions with filled arrowheads and [guard] labels; self-transitions render as a small loop - States referenced only by transitions are synthesized as boxes - DiagramTypeRouter dispatches to the state strategy on 'StateTransition'/'State' Design decision: transitions use orthogonal routing (ChannelRouter) rather than Bezier curves. New fixture test/SysMLModels/Custom/traffic-light-states.sysml. Visual gate passed. All tests green net8/9/10; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ROADMAP.md | 15 +- .../StateTransitionViewLayoutStrategy.cs | 334 ++++++++++++++++++ .../Rendering/Internal/DiagramTypeRouter.cs | 22 +- .../Semantic/Internal/AstBuilder.cs | 82 ++++- .../Semantic/Internal/SysmlNode.cs | 22 ++ .../StateTransitionViewLayoutStrategyTests.cs | 99 ++++++ .../Rendering/DiagramTypeRouterTests.cs | 12 + .../Semantic/WorkspaceLoaderTests.cs | 49 +++ .../Custom/traffic-light-states.sysml | 16 + 9 files changed, 641 insertions(+), 10 deletions(-) create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs create mode 100644 test/SysMLModels/Custom/traffic-light-states.sysml diff --git a/ROADMAP.md b/ROADMAP.md index 1da6eacb..5b3be75a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -484,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. 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..f048958a --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs @@ -0,0 +1,334 @@ +// +// 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 = 160.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. + AddTransitions(transitions, stateRects, nodes); + + return new LayoutTree(force.Width, force.Height, nodes); + } + + /// 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. + private static void AddTransitions( + IReadOnlyList transitions, + Rect[] stateRects, + List nodes) + { + foreach (var transition in transitions) + { + var label = transition.Guard is { Length: > 0 } g ? $"[{g}]" : null; + + if (transition.Source == transition.Target) + { + nodes.Add(BuildSelfLoop(stateRects[transition.Source], label)); + continue; + } + + var from = stateRects[transition.Source]; + var to = stateRects[transition.Target]; + var source = AnchorToward(from, Centre(to)); + var target = AnchorToward(to, Centre(from)); + + var obstacles = new List(); + for (var i = 0; i < stateRects.Length; i++) + { + if (i != transition.Source && i != transition.Target) + { + obstacles.Add(stateRects[i]); + } + } + + var waypoints = ChannelRouter.Route(source, target, obstacles, TransitionClearance); + nodes.Add(new LayoutLine( + Waypoints: waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Filled, + LineStyle: LineStyle.Solid, + MidpointLabel: label)); + } + } + + /// 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 midpoint of the box side whose outward normal best points at the target. + private static Point2D AnchorToward(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 ? new Point2D(box.X + box.Width, cy) : new Point2D(box.X, cy); + } + + return dy >= 0 ? new Point2D(cx, box.Y + box.Height) : new Point2D(cx, box.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/Rendering/Internal/DiagramTypeRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs index 5ef5b99a..bc6ff3ff 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs @@ -40,26 +40,32 @@ public static ILayoutStrategy GetStrategy( _ = workspace; unsupportedMessage = null; - if (viewNode is SysmlViewNode view && IsInterconnectionView(view)) + if (viewNode is SysmlViewNode view) { - return new InterconnectionViewLayoutStrategy(); + if (Matches(view, "Interconnection")) + { + return new InterconnectionViewLayoutStrategy(); + } + + if (Matches(view, "StateTransition") || Matches(view, "State")) + { + return new StateTransitionViewLayoutStrategy(); + } } return new GeneralViewLayoutStrategy(); } /// - /// Determines whether a view declares itself as an interconnection view by specializing (or - /// being named after) a view kind whose name contains Interconnection. + /// Determines whether a view declares the given view-kind marker in its name or a supertype. /// - private static bool IsInterconnectionView(SysmlViewNode view) + private static bool Matches(SysmlViewNode view, string marker) { - const string Marker = "Interconnection"; - if (view.Name is not null && view.Name.Contains(Marker, StringComparison.OrdinalIgnoreCase)) + if (view.Name is not null && view.Name.Contains(marker, StringComparison.OrdinalIgnoreCase)) { return true; } - return view.SupertypeNames.Any(s => s.Contains(Marker, StringComparison.OrdinalIgnoreCase)); + return view.SupertypeNames.Any(s => s.Contains(marker, StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs index 7b7ef40d..b9aba8e3 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs @@ -182,7 +182,29 @@ private string QualifyName(string name) /// public override SysmlNode? VisitStateDefinition(SysMLv2Parser.StateDefinitionContext context) { - return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "state def"); + 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, + }; } /// @@ -291,6 +313,44 @@ private string QualifyName(string name) }; } + /// + 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. @@ -766,6 +826,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 b0408d5b..b7870860 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/SysmlNode.cs @@ -16,6 +16,7 @@ namespace DemaConsulting.SysML2Tools.Semantic.Internal; [JsonDerivedType(typeof(SysmlViewNode), "view")] [JsonDerivedType(typeof(SysmlViewpointNode), "viewpoint")] [JsonDerivedType(typeof(SysmlConnectionNode), "connection")] +[JsonDerivedType(typeof(SysmlTransitionNode), "transition")] public abstract class SysmlNode { /// @@ -133,3 +134,24 @@ public sealed class SysmlConnectionNode : SysmlNode /// 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/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs new file mode 100644 index 00000000..51566e5b --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs @@ -0,0 +1,99 @@ +// +// 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); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs index 3cd9fbd9..99d94c47 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs @@ -56,4 +56,16 @@ public void GetStrategy_PlainView_ReturnsGeneralViewStrategy() 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); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index 9d20b9ee..7c4bb802 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -591,6 +591,55 @@ part def Drivetrain { } } + /// + /// 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); + } + } + /// Asserts that a feature with the given name has the expected keyword and typing. private static void AssertFeature( IEnumerable features, 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 {} +} From 7c7ecc44c17976efb8dfea3e13d563cfa765b860 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 16:09:42 -0400 Subject: [PATCH 05/14] [Phase 10 complete] Action Flow View with layered (Sugiyama) layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout engine (Layout/Engine/): - LayeredLayoutEngine: simplified Sugiyama framework — DFS cycle removal, longest-path layer assignment, barycenter crossing-reduction sweeps, coordinate assignment; 5 unit tests Semantic model: - AstBuilder.VisitActionUsage (keyword 'action') and VisitSuccessionAsUsage (source/target via connector ends, stored as SysmlTransitionNode) - VisitActionDefinition collects the action body via CollectChildren Layout / dispatch: - ActionFlowViewLayoutStrategy lays actions out top-to-bottom in layers (LayeredLayoutEngine), adds a start node (filled circle) into the initial actions and a done node (bullseye) from the final actions, and routes successions as downward flow arrows (ChannelRouter) - DiagramTypeRouter dispatches to the action-flow strategy on 'ActionFlow'/'Action' Design decision: decision/fork/join render as regular action boxes (dedicated shapes deferred). New fixture test/SysMLModels/Custom/order-action-flow.sysml. Visual gate passed (branch + join, correct layering, no overlaps). All tests green net8/9/10; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 1 + ROADMAP.md | 17 +- .../Layout/Engine/LayeredLayoutEngine.cs | 345 ++++++++++++++++++ .../Internal/ActionFlowViewLayoutStrategy.cs | 304 +++++++++++++++ .../Rendering/Internal/DiagramTypeRouter.cs | 5 + .../Semantic/Internal/AstBuilder.cs | 65 +++- .../ActionFlowViewLayoutStrategyTests.cs | 108 ++++++ .../Layout/Engine/LayeredLayoutEngineTests.cs | 118 ++++++ .../Rendering/DiagramTypeRouterTests.cs | 12 + .../Semantic/WorkspaceLoaderTests.cs | 46 +++ .../Custom/order-action-flow.sysml | 24 ++ 11 files changed, 1042 insertions(+), 3 deletions(-) create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/LayeredLayoutEngineTests.cs create mode 100644 test/SysMLModels/Custom/order-action-flow.sysml diff --git a/.cspell.yaml b/.cspell.yaml index a4cf8bbd..2d2f7ea9 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -48,6 +48,7 @@ words: - setlocal - Skia - Subsetting + - Sugiyama - SkiaSharp - snupkg - sonarmark diff --git a/ROADMAP.md b/ROADMAP.md index 5b3be75a..ac21f1a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -277,7 +277,7 @@ Grid, Browser, Geometry) require no engine — pure arithmetic in the strategy c | `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 | — | +| `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 @@ -549,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. 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..33df46a9 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs @@ -0,0 +1,345 @@ +// +// 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, 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; + } + } + + /// Assigns absolute coordinates: layers stacked vertically, nodes spread horizontally. + private static LayeredResult AssignCoordinates( + IReadOnlyList nodes, + List> layerGroups, + int[] layers, + double layerGap, + double nodeGap, + double padding) + { + var n = nodes.Count; + var rects = new PackedRect[n]; + + // 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; + } + + // Horizontal positions within each layer, left to right. + var maxRight = padding; + for (var l = 0; l < layerGroups.Count; l++) + { + var x = padding; + var layerHeight = layerGroups[l].Count == 0 ? 0.0 : layerGroups[l].Max(i => nodes[i].Height); + foreach (var node in layerGroups[l]) + { + // Centre each node vertically within its layer band. + var nodeY = layerY[l] + ((layerHeight - nodes[node].Height) / 2.0); + rects[node] = new PackedRect(x, nodeY, nodes[node].Width, nodes[node].Height); + x += nodes[node].Width + nodeGap; + } + + maxRight = Math.Max(maxRight, x - nodeGap); + } + + var width = maxRight + padding; + + // Total height: bottom of the last non-empty layer, plus 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); + } +} + 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..e8058820 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs @@ -0,0 +1,304 @@ +// +// 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])); + } + + AddSuccessionEdges(edges, rects, nodes); + AddStartAndDone(actions, rects, edges, layered, nodes); + + var width = layered.Width; + var height = layered.Height + (2.0 * MarkerBand); + return new LayoutTree(width, height, nodes); + } + + /// 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. + private static void AddSuccessionEdges( + IReadOnlyList<(int From, int To)> edges, + Rect[] rects, + List nodes) + { + 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 waypoints = ChannelRouter.Route(source, target, obstacles, FlowClearance); + nodes.Add(new LayoutLine( + Waypoints: waypoints, + SourceArrowhead: ArrowheadStyle.None, + TargetArrowhead: ArrowheadStyle.Filled, + LineStyle: LineStyle.Solid, + MidpointLabel: null)); + } + } + + /// + /// 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 centreX = layered.Width / 2.0; + + // Start marker above the first layer. + var startY = MarkerBand / 2.0; + nodes.Add(new LayoutBadge(centreX, startY, MarkerSize, BadgeShape.FilledCircle, null)); + for (var i = 0; i < actions.Count; i++) + { + if (!hasIncoming[i]) + { + nodes.Add(FlowLine(new Point2D(centreX, startY + (MarkerSize / 2.0)), + new Point2D(rects[i].X + (rects[i].Width / 2.0), rects[i].Y))); + } + } + + // Done marker below the last layer. + var doneY = MarkerBand + layered.Height + (MarkerBand / 2.0); + nodes.Add(new LayoutBadge(centreX, doneY, MarkerSize, BadgeShape.Bullseye, null)); + for (var i = 0; i < actions.Count; i++) + { + if (!hasOutgoing[i]) + { + nodes.Add(FlowLine(new Point2D(rects[i].X + (rects[i].Width / 2.0), rects[i].Y + rects[i].Height), + new Point2D(centreX, 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/Rendering/Internal/DiagramTypeRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs index bc6ff3ff..10c05830 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs @@ -51,6 +51,11 @@ public static ILayoutStrategy GetStrategy( { return new StateTransitionViewLayoutStrategy(); } + + if (Matches(view, "ActionFlow") || Matches(view, "Action")) + { + return new ActionFlowViewLayoutStrategy(); + } } return new GeneralViewLayoutStrategy(); diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs index b9aba8e3..e56c835c 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs @@ -176,7 +176,70 @@ private string QualifyName(string name) /// public override SysmlNode? VisitActionDefinition(SysMLv2Parser.ActionDefinitionContext context) { - return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "action def"); + 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, + }; } /// 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/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/Rendering/DiagramTypeRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs index 99d94c47..da16bef5 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs @@ -68,4 +68,16 @@ public void GetStrategy_StateTransitionNamedView_ReturnsStateStrategy() 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); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index 7c4bb802..48d04491 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -640,6 +640,52 @@ state def Light { } } + /// + /// 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); + } + } + /// Asserts that a feature with the given name has the expected keyword and typing. private static void AssertFeature( IEnumerable features, 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 {} +} From 98a64e8eae9e0b80386722173043dad391bf3ae5 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 16:27:34 -0400 Subject: [PATCH 06/14] [Phase 12 complete] Browser View and Grid View (relationship matrix) Layout (pure arithmetic, 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-to-child connector lines - GridViewLayoutStrategy renders a specialization relationship matrix (definitions x definitions, marked where the row specializes the column) via LayoutGrid with styled header row/column Dispatch: - DiagramTypeRouter dispatches to the browser strategy on 'Browser'/'Tree' and the grid strategy on 'Grid'/'Matrix'/'Tabular' New fixture test/SysMLModels/Custom/catalog-browser-grid.sysml. Visual gate passed (indented membership tree; specialization matrix with correct marks). All tests green net8/9/10; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ROADMAP.md | 25 ++- .../Internal/BrowserViewLayoutStrategy.cs | 165 ++++++++++++++++++ .../Layout/Internal/GridViewLayoutStrategy.cs | 137 +++++++++++++++ .../Rendering/Internal/DiagramTypeRouter.cs | 10 ++ .../BrowserAndGridViewLayoutStrategyTests.cs | 89 ++++++++++ .../Rendering/DiagramTypeRouterTests.cs | 24 +++ .../Custom/catalog-browser-grid.sysml | 19 ++ 7 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs create mode 100644 test/SysMLModels/Custom/catalog-browser-grid.sysml diff --git a/ROADMAP.md b/ROADMAP.md index ac21f1a7..dceebde5 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 | +| 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 | 🔴 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 | +| 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) | --- @@ -672,7 +672,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. 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..1bf8af6c --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs @@ -0,0 +1,165 @@ +// +// 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 vertical stem to this row. + 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 dropping from this row's left edge. + var stemX = x + (Indent / 2.0); + foreach (var child in node.Children) + { + EmitNode(child, depth + 1, theme, rowHeight, nodes, ref cursorY, ref maxRight, centreY, 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/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/Rendering/Internal/DiagramTypeRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs index 10c05830..3e2cf20b 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs @@ -56,6 +56,16 @@ public static ILayoutStrategy GetStrategy( { 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(); + } } return new GeneralViewLayoutStrategy(); 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/Rendering/DiagramTypeRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs index da16bef5..c8f00244 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs @@ -80,4 +80,28 @@ public void GetStrategy_ActionFlowNamedView_ReturnsActionFlowStrategy() 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); + } } 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 {} +} From bb4c4e1ccadf43538cf375338d95511d4542e6a2 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 16:47:22 -0400 Subject: [PATCH 07/14] [Phase 11 complete] Sequence View with lifelines and messages Semantic model: - AstBuilder.VisitMessage captures message usages (name + from/to event references) as SysmlConnectionNode with keyword 'message' Layout / dispatch: - SequenceViewLayoutStrategy renders 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, labelled with the message name; self-messages render as a small loop - DiagramTypeRouter dispatches to the sequence strategy on 'Sequence' Deferred: activation bars and combined fragments (LayoutActivation primitive is ready). New fixture test/SysMLModels/Custom/client-server-sequence.sysml. Visual gate passed (two lifelines, three ordered messages with correct arrow directions). All tests green net8/9/10; lint clean. With this, 7 of 8 SysML v2 view types are implemented (only Geometry View remains deferred). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ROADMAP.md | 15 +- .../Internal/SequenceViewLayoutStrategy.cs | 229 ++++++++++++++++++ .../Rendering/Internal/DiagramTypeRouter.cs | 5 + .../Semantic/Internal/AstBuilder.cs | 26 ++ .../Layout/SequenceViewLayoutStrategyTests.cs | 109 +++++++++ .../Rendering/DiagramTypeRouterTests.cs | 12 + .../Semantic/WorkspaceLoaderTests.cs | 42 ++++ .../Custom/client-server-sequence.sysml | 21 ++ 8 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs create mode 100644 test/SysMLModels/Custom/client-server-sequence.sysml diff --git a/ROADMAP.md b/ROADMAP.md index dceebde5..0b61671b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,7 @@ onward) needed to reach full SysML v2 view coverage. | 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 | 🔴 Unsupported | +| 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) | @@ -622,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. 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/Rendering/Internal/DiagramTypeRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs index 3e2cf20b..8a43f000 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DiagramTypeRouter.cs @@ -66,6 +66,11 @@ public static ILayoutStrategy GetStrategy( { return new BrowserViewLayoutStrategy(); } + + if (Matches(view, "Sequence")) + { + return new SequenceViewLayoutStrategy(); + } } return new GeneralViewLayoutStrategy(); diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs index e56c835c..068923bc 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Internal/AstBuilder.cs @@ -376,6 +376,32 @@ private string QualifyName(string name) }; } + /// + 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) { 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/Rendering/DiagramTypeRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs index c8f00244..b6a965a4 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DiagramTypeRouterTests.cs @@ -104,4 +104,16 @@ public void GetStrategy_BrowserNamedView_ReturnsBrowserStrategy() 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 48d04491..6857122e 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -686,6 +686,48 @@ action def Flow { } } + /// + /// 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, 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 {} +} From c3f6fa48b68a685a040db372165cb44d84c72bac Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 16:48:41 -0400 Subject: [PATCH 08/14] =?UTF-8?q?docs:=20update=20ROADMAP=20status=20?= =?UTF-8?q?=E2=80=94=207=20of=208=20view=20types=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark Phases 6-12 complete in the architecture status table and LayoutTree vocabulary coverage; update view coverage summary. Only Geometry View (Phase 13) remains deferred. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ROADMAP.md | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0b61671b..3d883382 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -751,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. --- @@ -823,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 | From 9ec77155edcf3d8b1d438afc19cbe53632f657d5 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 18:22:16 -0400 Subject: [PATCH 09/14] fix: connector/label rendering defects found via SVG self-inspection; add diagram gallery Rendering fixes (verified by rendering SVG->PNG with resvg and inspecting): - SvgRenderer: box name labels no longer stretched to fill the box (textLength is now applied only when the text would actually overflow); same fix for LayoutLabel - SvgRenderer: midpoint (message/guard) labels now draw on a white background so the line no longer strikes through the text (matches the PNG renderer) - ChannelRouter: optional source/target PortSide anchors emit a perpendicular stub so connectors leave and enter boxes at right angles instead of sliding along the edge - ChannelRouter.Simplify: no longer collapses direction reversals (U-turns), which preserves the perpendicular stub; also drops duplicate points - GeneralView/StateTransition/ActionFlow/Interconnection strategies pass anchor sides so all connectors are perpendicular; General View row gap increased so specialization arrowheads fit - BrowserView: parent->child tree stems now drop from the parent's bottom-left and never draw over the parent's text - 2 new ChannelRouter tests for perpendicular stub entry/exit docs/gallery: a showcase of all 7 view types rendered to PNG and SVG from interesting example systems (drone, workstation, elevator, CI/CD pipeline, OAuth, vehicle taxonomy, avionics), with a README index. Models in docs/gallery/models, outputs in docs/gallery/{png,svg}. All tests green net8/9/10 (136 in main project); lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 1 + docs/gallery/README.md | 118 +++++ docs/gallery/models/01-drone-general.sysml | 67 +++ .../models/02-computer-interconnection.sysml | 32 ++ docs/gallery/models/03-elevator-state.sysml | 23 + .../models/04-pipeline-action-flow.sysml | 35 ++ docs/gallery/models/05-oauth-sequence.sysml | 38 ++ docs/gallery/models/06-vehicle-grid.sysml | 25 + docs/gallery/models/07-avionics-browser.sysml | 37 ++ docs/gallery/png/AvionicsBrowserView.png | Bin 0 -> 49527 bytes docs/gallery/png/DroneGeneralView.png | Bin 0 -> 56621 bytes .../png/ElevatorStateTransitionView.png | Bin 0 -> 23005 bytes docs/gallery/png/OAuthSequenceView.png | Bin 0 -> 15062 bytes docs/gallery/png/PipelineActionFlowView.png | Bin 0 -> 38160 bytes docs/gallery/png/TaxonomyMatrixView.png | Bin 0 -> 21288 bytes .../png/WorkstationInterconnectionView.png | Bin 0 -> 25126 bytes docs/gallery/svg/AvionicsBrowserView.svg | 96 ++++ docs/gallery/svg/DroneGeneralView.svg | 126 +++++ .../svg/ElevatorStateTransitionView.svg | 66 +++ docs/gallery/svg/OAuthSequenceView.svg | 61 +++ docs/gallery/svg/PipelineActionFlowView.svg | 80 +++ docs/gallery/svg/TaxonomyMatrixView.svg | 472 ++++++++++++++++++ .../svg/WorkstationInterconnectionView.svg | 70 +++ .../Layout/Engine/ChannelRouter.cs | 85 +++- .../Internal/ActionFlowViewLayoutStrategy.cs | 4 +- .../Internal/BrowserViewLayoutStrategy.cs | 8 +- .../Internal/GeneralViewLayoutStrategy.cs | 28 +- .../InterconnectionViewLayoutStrategy.cs | 4 +- .../StateTransitionViewLayoutStrategy.cs | 21 +- .../SvgRenderer.cs | 51 +- .../Layout/Engine/ChannelRouterTests.cs | 45 ++ 31 files changed, 1545 insertions(+), 48 deletions(-) create mode 100644 docs/gallery/README.md create mode 100644 docs/gallery/models/01-drone-general.sysml create mode 100644 docs/gallery/models/02-computer-interconnection.sysml create mode 100644 docs/gallery/models/03-elevator-state.sysml create mode 100644 docs/gallery/models/04-pipeline-action-flow.sysml create mode 100644 docs/gallery/models/05-oauth-sequence.sysml create mode 100644 docs/gallery/models/06-vehicle-grid.sysml create mode 100644 docs/gallery/models/07-avionics-browser.sysml create mode 100644 docs/gallery/png/AvionicsBrowserView.png create mode 100644 docs/gallery/png/DroneGeneralView.png create mode 100644 docs/gallery/png/ElevatorStateTransitionView.png create mode 100644 docs/gallery/png/OAuthSequenceView.png create mode 100644 docs/gallery/png/PipelineActionFlowView.png create mode 100644 docs/gallery/png/TaxonomyMatrixView.png create mode 100644 docs/gallery/png/WorkstationInterconnectionView.png create mode 100644 docs/gallery/svg/AvionicsBrowserView.svg create mode 100644 docs/gallery/svg/DroneGeneralView.svg create mode 100644 docs/gallery/svg/ElevatorStateTransitionView.svg create mode 100644 docs/gallery/svg/OAuthSequenceView.svg create mode 100644 docs/gallery/svg/PipelineActionFlowView.svg create mode 100644 docs/gallery/svg/TaxonomyMatrixView.svg create mode 100644 docs/gallery/svg/WorkstationInterconnectionView.svg diff --git a/.cspell.yaml b/.cspell.yaml index 2d2f7ea9..b2e7ec06 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -40,6 +40,7 @@ words: - Noto - Pandoc - Qube + - Quadcopter - Reingold - reqstream - reviewmark 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 0000000000000000000000000000000000000000..91b6b1c1a84d5a51f87ec7de7a3d979068fbe4dc GIT binary patch literal 49527 zcmb4r1yogS*X^MsloAPPFi;T?ltwyKT0l@5q`Q&s4hv}zP>}BKZV>4%rBk}^IsfQIHRnEoaxxOQIOI481OoTz6Vc}g1d1R6fx3uw34U{^ouCch z&~1dDDq_Kx3)brZ1cDavR8&aOF>bBeS?%Mmp^FQnQfF&9MoSm_dc*Vp>)R!PG4!_< zut(^VS+Bq(vMahBVAJI%g+c7TP5@pR7RBoPN6h!aI+x=+gh--hT$W`x0|$ zmJka+Tu`lIz|=zNzKpQ&RsF7j#&Cg>ee81dKIP>?yu8vv=L3|5FDAw--8>HaYs&;U zi(21lIfGYTKP?wH9|A13r`$IBFU43SJBR~+Ab&qm{?74tyXWEY>MOPK-)7;zh6V<1 z+`UW0>#(4;Ybbl!@EO5z{xkG8F?1!R!6#3ic==1v4rOtVF{pHpc*1-3#85uF{3u|c!Qzql|badE>t}l{w zci?TGGWK}(DcS6=A#_3hBx`Ia9n=B zzBT2_Nz?n7K@bMEzY&rASu0UImZj!f!~0vp_g~UC!Ky|HX!^u*jw28l7$}YdmoWK`0O6(dUmMGIF!{c1lKpst&_1eHGXqJjMTW2!n^5oe3aumh(plJ$Aqmh zsZO<~O1~lFiweIb>0@YI5!BY^=zVu=v~U`MI6W1g{}suA>bP{xpc`k{Fy)-nA&ZfU zjjbRWkJJ3l*70gPkBsG}#@)iBk9U)}-V|Il88Yq5OrxW?fcw9H&t^66=QX+&%#u+z zzx};d03Ts_Xc_H?=Y6T1j)UXJ%;A;xh<4_Dn^Y<6W|-OOf%^{onwBXu9u0G2<>tb? zFR2#+!HjpKf9C`938^?P`9K&W!b3Xtw{6W8Y=*D>S9Ekp99o+0bBy&&KK|Sf|8^RD zD|(P$EiH^|5A8OlH?`bz)hoaL^|#$+n|PdN=^>4zvQa(fjl<>sMV>TS_lPsIpi|^E zG;W64Y-RRovWcs<4){xImG+Vb1_liMy2rc9c4KQ}#kHOJ)3wt{bde^tcB_~?Y}{OD z<@wf&oE#c`c^GMpzL)LdH#Sh>bF24XMDELNN=z;s|FqZBHSf`IUT4~mu6XRQKGxgg zzFu6aQhTuJkhg8QKDftuJ2E!yhC~PZnsJAk;kZZPhhwtfAUxwPfBvy?3x|qIOxul7 z1=BpLzS8^^^3TD+_nymXc9z?wUr&*6PR__6_s5Hv-I`?!wK+d z_QgukVvCk(iioE~!i3{m&veQs@zn0ow#=}Uk7NIKd&%vg?J+Cy2ESFX=#e>!DIq%j1h&fYSHy!58i7kNt+v;ZmKScA2(8mYR`I>O zTg=RSYa{PMM@F8&h)u^n`n#X+5Iwnx(G@KfN9GksB(gPXKvpT%m`DdH?p>(`0m)9Osg(^aD_^fEo$sCDb(4pxVj*TZNoD08c>`pwKB(9sWPny{77NeN~e z>BY>rsE}uCq1$9;u0dD+_M&2iH!FqB#jm)D5;KgpUefu2^uj2 zzFrma`<7gF>cR>c-Hjr#*6%duC;8Y{4hLqehHF_Xi1KE`9=dGxSR91oMtKrKu?N#} ziI&Un%61(b>9tA5=5dSMR-0aI%aeU|zN4Rl3*Wg8Pld=Hk9{aU*ryK6*Y(EjSUjHL z!1=_Y>ARRf^x!e~K>uJ=RP+7chlv+PLi+jzJ-Xzi%zU;|D^Av7@{^;d%d-7Qh$#%N zxhAKMY4&)2UUyezimS!+v=Kks@npT&v`a0KpFhQNUYsITDo{JTZUWZRL!XC=BJ-II zEZs=Kt>8VAC(K(9Vi@m#{9ICkvaunnd-U5^zZXL&%1epH$L(aDQ)MOkJ-x7S10O=r zGEboLE?1h;i`#u%oHukahH|8vLn&}zdScEeW})o-X74{l<|~$^q#zTmT#NnTkedN6BRuG38JocV*vkaUFGaz)b%PPL3~ zc9>+eGxOD#Gef{|!F+fRhmpyz((*{5jQ`?%G(e!!)g z*RbT6YePmxz7Bo&rFFsuW)xhlL~H8t67T!4@?O>p1B0}e@o~HkCj}`PP|egL5qlB`n6@PtwY#vM8|7GyGquzA_Fix(OtXs@jRv@$5MJZ9DV0Dy|pp z70a1AnNZB3ldIKZC>^gO={~5peCtbD%f=BRcy)TZb1~0Wnn;_m8DU8Kw_h#+Hk zx^{kh)Wm`v%j>)F^PA0|*3{;4omqFuuKf4(@4xcJIYqn&@*jPUZ05d?{Vm+||WO?UIF{ru8ND&hi@-uzMT@A2{RWv(Z92`;~}RqfIn zLjsnUmX ze(RTcPmh?@JH`1z3~b@9pg5x;73YW1nFW?>hS5Ez#U({LW1SCYR~NnAT@-&Xuv#;`Zd{Yz7gd{^(Stzfcg5g+ zw$SLU#*O`27tZ$^3y=Dg!NukFY=rW{4G#PCK6mT#WftK!wFnG>T){YAg>HUchg5Pm z4bL{UuNefbU*9RmDB1<_@_ZbDNiXyU>aMRa4;Ai|4}3TYk<=d48&J>kPgJJmACdo@ zqF^G)$YHS904pAte6#3uB)PxkEuLmVR^9YLX2z)A7d_+MabmmTjDom_`qKU|F?-U0 zm)#Q^V~aEX{!!*|k;|I`;S^bBCIJ`({5T}@nCandhx0hDk2JhJW0CORYpW776O;a> zgT1f8c|43%+^U7F+)_$3N2$ruB54)Dds1O{=a+k2D1Ho%?0uE(ty@$g-aEHDB?@It zJQh}xa9Sy{thOH<)|AcFlW0yg@$ko67cyS4#zOD?Tv*uezbMEKba@I}N zuP-rmGLjIB$)6Yw`>JMEgQIQAS4rz~=7NHVBxgZEFl#z3y)CaT*Hu%s!nwoe`U^uu2?u+k(kbhRMb9LE>+ zsm1M8u6nqBYCHkIUJeDWm{SzHWzAvUm$_Ke;5t&qOyP0Q_1DhP(sZo=VlYx%{MGGi zI2a^1vGQq!0{YW3zUOKV&zqYWHK*f5=#n+LlbsQhKBWr@_1F4_xO~;;c4+Iw5l3of z{;FhRg1NbQKnxZ3OYb<>cSXg;vv4Q4VS~s_-&dw9d&B)Eqw@JiIDFa-m!lrcVtg3y zjju$nJvDx%x}mzWn|`k%uZUlmQwtcxHT*zDBq-$Q^{XU zlBA)z86)l9J=PmK74;v};NVVT=pY0I3DSberd`f(Tcoj;OI!!`aw}EvVKeY9=CL$l zuBP^#uNPeli6!tVDdF%5a}MPGn)B1zX=6;G?)eMm=fBQ77kA#pRiFYBK~e)scUvF* zb-iV}K$+u8n_;}vw7c`H4mITgg^cRbYLzr!f6&AmG*K2g zv(mJlEdNAx-G3Cr-o~c!wlg&FU$=N;zv$=TVstznfdg2| z1V}o|x*T&OW|{+uY{z1LXv*g3Nu&+k7GG);lSfM=3xMeFQH)>TZt>vWF?*sMw$Z~Bd5H#A zj8VF~i+fqpU3$5fWY}7>F^N)3P!0jdaHKRfH!=Jb&o}H+jhiNK=Plm5lHVmEtwzZR zMn}MVVIZ*l_^J@)7Qbd#>v?{2ziAeG{)u{0aboQgUdv2PHAE`V17!tT zB^4Sf^b&O?I^XW~kSlc@s(x%Y@8i=nM-H;TD7yfSlZu;4LXBJ6LH{ z_tgi$#qqHl>&cMmQ4+>oqxSn1TjbfVNPo!Sm)W8^MmFUogxEBIQk@u5?zyrmTdU@&<&Bmx&zI&A37 z5TWHmy3A(EZtGC#G_BuDKYOuNyFS`W?LE6t|2?)*`p*QECA(n7ZA>DMe`v06FR$iS z4j0^*e_-N4xiYQABIDGjHQwAwqT9Joxpm~Se?MC5o1iY|J4vV@#vLA=#8uV}41X+Y zSBvz7Y#EpFO|%oAscotNyT*-Qe^u+s#<=P@Yx?T>kpTlrltE(Yd!l@ET@uJ)a2jTI zj?|e)VkNCtaK3y^V=FJUm~1KArb8%d>fGRyc%TaRGaXGxnSTHqB?6N}ZU;ot7yp~$ z@j-BS4QfJ{$NI~ahMPa`4y(97hBF$Wp-vn&j*+T7MIv0kmF;~u(y zFf^TLrT&}dQuR4&RA(gf{NC`VF(i{IIy^e=-G7nPx@(NNwA%T2SZ^=X*o(HSsqw6K zcJ_SQaV=kCUHr37jC8b~@7qoFI-W`5E1A_(h&%42Kp@`tr-zkZ?cdV>FHXN<$j z18_cosUn{XP<(3#r>K-%ruUTFDoTJsCT&embJcsvb!1m)$@ySIx6b3dWbFr?k%>uT z{tvd()kB82>}^Pn0^lTk_$p3h$kx?No{xuz=RQZtZ{1c_?Kwx>H=j1VmncRap-M@P zd+hIzmn{9zHUi9vl_bi4%!vs}kSbg6CZn0XJB4VUy4`hT*SufwFhVLZ` zydMyVoYIRfrI+MvMR&(oJhOztigp!$ns&FWWZA+u{pPaD_sS0W8m;CuYj*^X^5?TA z7uM7O8S}Th5{c5Z{Wszo0=rM z0Tp2iD{0H|gVz!w6~I`+MCGp*_MTT`c4Zt%sgTB+d7W-J)He2|oa9^1@?E*EQ&yG8 zjl_h5lEoW$xUk@)0MM)U*H3H;nn^POxD!GG2RM9JK_5yr{qBIjWO2c>cd;;e&il~Y z_Bg##CH!l!C;+gwZ*gdQ&uE$Npcd6(li2;;oon}> zUsN1Zq%l4ojm=*spNV1>B71nd?dQJQODWx~mx+NMg6_aOLtzSB3U(xf@`cd97D72} zmAI9)zOGtom63c^IVNUyt^s-Gk&E-GR%z3BbnHiv1Lx1W;a z$7ZkaA~CyV)}69GJ?L5dPFXPP9j>;SQtSGYN?Mb*x|1#UL+1t-I^6gVkC?|HgAR`d)kWv$c&$XP))P62?3Tpok~Vkk^4h@h(Q8GDNm2EKf#Aqs-gr8_Gz1 zt+XE)nzZ@C0Gp@-dkb5q-IBE&5M*C7H(!0--wki;nZiR!UdCS6K)=-B`+98tncd1L z2njs5CUZQ=_DcxhDzK-pnaDS%5Vy`+D?)Dvo$9Xov0nu(iX;j^p*z*)i6OYitf zd*Hab&gRrpYB5jbwOAVHC}~T%TotT{Jd9m2<%ktg73G6sV`E#$$+xx|va{0%%kC7j ze6KnvMNSwpW}-F-WV2P`C)gVl&( z*3>pKfVjn#Ce7!Q_W?!}&!5&EHAF<@K+$sv;CbHnEh~})Io<629(O&K2;sO8orZ#N zXCgoXx7EDJ){l=5Lir~qChqIB4t;v@S}#jXF3))K1R@C$^Nvqjlae0Q1#+~piSZ~8 z)i?rsx3wblB|?$2`Ddiaw*9N1mh$#=#j2i@ zveWenNLf^~=ve+>uoLNNw)8^9l@f{*yFE@vUM7qWjl#}16{h2&*{0?DGA5@Lh$E0! zt=f;NG=yb@<`SA}FhHT?^kgot*JNMJ^f~dysKEsm;_&?BvVMH)I_j$PE&fug@850n z4RBszGh@slQ2Hd0|~u=!(svVrBPy%UNFoIiZi; z;hFp6f9sLN1!zP4ar z!+U&5_{i8!tglh8s!`32(Rhe6NO|u2{orsazOvG%xCl)@QXnl!VK? zThfA3!eOb$u0HgrazTGS6uZ!>4Kso)*+SYHRh~VPuIsG>0Z*=waScjheLlH+R%_pd z6kU$7W5|C+krM`f8#&tAKQM3=YsKFFy&L^@jWFfd_7;C`dBDT8dzN2RlIaS!W(eUr zd37ah7%qZGNrzfHzBzfYs4GQdmZNFX{7wh^ndBF& z=xaeQZFFqM$lj-0P##nESNVvojeNN~t8!q^w!hXUF_qxD)H1J0K;c6e)3zC^$Dq%Z>yb9v0Jv=4CNDMUTszM?V3Z)p5cL_@QcG9qc4 zBIdI0qCKX_urJ4OZn4hdCrWEKk|u2`T7*>87V2#&$NHc9HjB(ctrx`!{E{CO2>3YA zOUK3^ICMy^>4XOaHXls81*eL0Yb0>Qe|-jjk$Xk&8(E4!WIh_?#z$@3qpUk;!^vvO2x9{CWds5e7U!HX{ z&U`aheu$ot3RS6|USTdsO=e{kxTuH^DZcx$W(W@$EWHM0n$=oM?=buM#dUnor)(xY z%JZUcuG>guKOJ7(7n`BXP@m~EFs zCsocY^D#0hT^?^{CKodPA1{FXGvn|$rX+0cI(|gyNqSFVqkk;Z;&Jf{5?3puxU3Qq zh{wt$U9)HpV;95>|7*kW7K^*%c9F1KTfg@`xdiZ&W@oRVyuz~fs3sd0$_V(JE}!$k z>9O3s?oX}U-VhR3l~d6W*28*66HODxV}ZZfs_~WvET)q3Lsu=amk*}z9 zvS@{D@UDP$fZ3em$;tfB*4EH4O`6n^o*2_frWQCMt5Y&9o|BbttDHjpvI91R@bV(- z#CRowZk63Mshbht3w?uqnXpz09gbGIBxxbm@+-;9pKjlZJqz$Vd7Ff&ONgv=ILMLV zIVh^cgy@B!)fwkqg{YpHnye&yU`Y!jjs4o&zd>!P?XJ*ruk12<*E>tTUn}~3tkn0% z!covL=in_>B_%uB9=$mg9JZOgaLIeRn6X+1IYTA9|Alg?5ZS_V`+|Pd;*%~(m603K z=x0~I3={A_MD4Q@e#G7(4*@6XPKv0@3?|XFv&U0Aaw!>KWhTgLYp1L_J9}4f`!Zrg zx@gBkG;Q*I=?lS+?}O}pQ6b(Y%3UdXFYM7`Gi9GUU4qen<5v$fVPaA#*Cwi}a(y(W zVjaTJ(Fp-9Rw;}k|Lh)K;_3Ux$+|0tl`)qUdX&%C|oa>SueN@Mj^_xToE5XXy3dF;LE|BL(L54e2Seeg=ntRkc~ z;ICpi?_TZsF(?X4#Bb3Q8;2vEi9!ZBTd*5y`1bF#gwuE%Bpi8Hv*$Ac@{QzaM}YKz z)!+v&)%}-pu(We{9JWG)g264w{<@&^n?+d&p~hqPyZ3~+7?op>We9}u@nFH-|Y#I6pY`RTXJ;c?@^fwq~>3SQfF$FG@wLwVicxn6Sp`UmfOZs*;>BkSewdi_JTd{1X}vzL!^sBI81r{L(0NOLA`lz~dTbISgWZ;A zuNaX43bEMsMXoD`D5ZbF^Le1IKVI@&zwGycJ4oafZSL_etrh5r%cy&&6DY3fiy6LT zR{>d5J1UI<&dtlXMc0P+&0&pXBV4>@gcOF06({nT7MlLri8;=87xXiQC>m> z1O)0WTI0#_uB>+SBo3=R%E&z~pJ(vTe}f0W`DOPC$HoU5npV<8w}uXz78WAy{Y)#z zjzMV&cNPnpeLY=6^CB~=JVOEG9aqg+`*Rmu*k3S_Y8#8*gmtBeFlZwQ3lqsfLG`FS z>F08?6(C`b^QRNxGaYl-TYk1S`VQa069HfaIb{yu?a^3VZicXJ+}nr{wPH0N)pfHq zN29=NPcP3ywxNa9%ao2B$$$GaDIkofA)KJX0pNnx+ch%8eSb z10?e8v%$Zz0)-)Fcn>i;n9(9(bFMXDO~>(IlNl8d%SLC?lixrmBiX7 z)P5fy80hwMa*j#GT*X6ttK7mp2|0=7)z9E_EwOw}$%gt9%6BZ?7WC+e1jb+^?DuSJ zv+JA4QRSOzT)FA1bKmJ9p+^S488DIi^yrGD)1}HehN9OgrpN2)$${LDTlB%!clM{Q zN_zvb(Zi@5{)4;0SX}1CrAa_OU^C)5G!u_-AP&4*ZG>QLy@P&|5O&8Y#KygakQ@uV;@HseWZx_npw zDbfM5y<=B+qDUig%JxqD7`tx9k$dvWawcxt1%y5ouMJ)sCG5`1>9^Gks%`%HS410U&_}DbHNrFG%7P_~$HgTv&56IM#%%||n zZZT%H^DSBtE0CgsB|piBuZxR;HI1v0n)@rqZNO&lO z_x%|lb<+b!TLhBk@HT#q+IexMT7^E08np8O;tK8GYtN~JP39DqT(f4aOuid6844M{=mKzCf;7rUAtu+dl3y2qxv6TewqvD>^ysY6LKviLe@7ck6=2Fcr+At z2gTLh*KI1sggE>Yy25z`ic56HkBhrX;@ldm#b=Ei@Zkgl>Nl4-3^l6L)>Xqf*P0Q$ znIpyNY<|4$hy}<1OM=KS3AM2uiZPL>*LLt+m#$jE{lwGELPU4Zo*FTT>f8}?ACVDj z3bbO4kCyeR{$Upuyf2(52}mo|HVg2>VL$SZ@cqHot-uKkg|up)XkytmEhs! z%|72)F-SbSftWejWKpSPL}W~=A-G&ds+7lVD(fDvW-(!bpac~cl>I1_`#vv4#iT{Ri{hNw z#{jSMld`|Qha1V-kZIwwiQXr7Vu6lMc0PC|iN@OFRmc|kU<$P5eV77cxTdD=?HVny zlKp2p>OZRO|Lk9Frlb0tx%vSd24PV2GeV(#gO7^s^yLIX0qX8Tr(S zbIB-0<7p`EF)H45l%D8^m!JJY`|{OH5Ue!LnfqK{&oSmU^-uAP?m<0JA7mr1s|WO0UM_A_D(Z z_9^pG|Dnzu(|gkfbz>M6Pe72YfDlwq3ODEpiFs+J&d(7Zf0xpIk@i+;0Z}UZoTS*s z60UwNIzhNCvUs}oSN`BV-}(lMAThT4_ADRged#bIh!@ z4PO2O;LE0e0Q~hI0LQm`#DVWD;7D_{ssE6oi18Y-8vvB*d!J?^^{0YTI$pw(db%5e zVXV4d5A>zpwy&2LL`~khdxHqp@(^IHbD>%A`JI+zP9l_VE8Pq0qpkFWf_B^PP~8T5 zAeIJ7LjOb6tj_QoO)<=qajM$B^TqD`wj4lW=BHqlSYS9neHuE)CDOWw4Qd)5$jXW~ zHT@0Y)vnRr`zlh!^*?@ILl4KT&)(g2SGlU0YVsCNYR_{Oan3ed6EG7DMj(O=au zKynxzk4&4br5(kW(Ozk#kAsjSn}X*bg-sqFRgAjEcp*oEl6Iny6XRQ%xV=>efE;&+ zMFd_0iLIIO-8&vUxoJL2snCD{qegB{h>N1?%7=?SdbF5^Xh`1DhZ4EaW^4kr;1Yy3*Ezn8w@`cp}7-5($`2(F!l=Z zxDX|B<5D=T(AEgS+O!e^>Ri&Eguy4nd);WILyiE9?U}=$X~HqzxKbeoW75)hF`S7~ z0kXZ21QnWc_JzUEpQ9i)MEp=4%S@a$H`L=FeJ4Vc}aUcW84k4On!jU^sYs zEZ1#+%%qr$45Yn#?FK+1Nrc*8koY=NKyyTa0#PTMojp?G#?&8AF>jg8>9`t;X-8o= zo}Q_$=K9wFX7eiR_GvYCFG;S#yATj(X7tYhaRjhIDB@}T4QVgU|1+f7j@|rfkQYDr z&mxyyGvfTegEYhDnB9R(9`}Ab**9UORx&1r4~}y4^oUOLA?hHoku^26Uq1B3O9xDu zTQoePUS3-(a>8xn(GSaG>fyqdCu*H99$wIytbiVH1V6Ue zzdeCqNYsS-y*JVc^AWyJxg7dm&kYcMCV^JjJWKLdc$$D#YEIzdC>#pn{=W<2gsv5e zd30^UmM~Z9-p8CxQ^vb5|A5IS4JwqJzl7sGP1I*xw-XW?^YkP?K?mB2^`F8qc<2^t zzkIYbkBP|X=gi6!b zwNg7@0oxt2%Kf7urw4+s4U`wr?y#l}RMzAhI+h^j@=}9KXh$Q|H~4a|$2BiY7t=X} z`$6S&l4fnIJmAvlsW2fmKPj~PSdVjgS%g9G7EQi`z1X98L;fRDM@9}uqP6i=7Kq2& z^xB~m3(1kK#W8B!9FoxHJZ=@0akmy!W2>Gg-&P&mzLNMt2#gm^)CE0NHf-$3vt7UW8u&k z=w+8c@&hsMTW0p^^9vA2?_9%4U0qgCS{mNewlxfTT+I4t9~?#q52Qb?c=0@ehiT#_ zOy}yzt{$=`)b5I+{fAR*th#0>t5;O;vp~)x_8UC(mykvmMs7j`SSG`3pY9ud@c?=0 zk$Qy}NVDIEhWwBwuezMa7?vky*;2eK<>_UCS?fn&NXR#+RcSoBzM|VEhZ07mM2s>0 zsg@XQTz|{PS!VyVp!FH;3@+zB8rlsZyhe%s(wh=QuSGJ>+hE@NIY12Nw4@Y~^bgb& z1!){DYZL_6kP_z_GlA}QK{Nc3)!9D;Ut7KiHYho5_qep)mXuo7uJ9Xg5SWk1mVX7> z7-S&*l{UhZao%O=j%4X&>1Njqr#(RJ6B?qGc;$2ttZ98*`rpEZPKBiS8Negy-4B0h zMXFM*-~p@1pN$RiuS1hByn%e9mP?@`a8Yvd4Z9ZOwuCXV!rb7?_~4B+kiVf4q82i;fr=8US6MQ*|gmMGxt`+n?Z*iYMuz7gRVQ1LZ%( z6&V6|R2~{r&vH-Pe{5o6jDiqJq{^`AYWDSy%N_;eUtsNKq!vh&e_12jhAE|}P6mu- z!RhD^dwU7((iH^4$Ithb7=zb$|3-c+q?C(zG}I~X^k#0lmfIV`5l%oWcqc-(D3h$1 zNsYk5kebi@Egp4ql%?f&6DJgKyJhn&$1^`kNuQ2#Kl_@xCgd_0%53uX2Dcr?he9wT zft-4Kd=?HO2;YsMzoK}a^R$+qgSSe^(~$whM)>`_)Zoid`{T9r(7XE>)Zz&cMSq#b zb{{G+crD$%5pFld(Xzg*+XgR;tA2Q^T2>sJ09ELrwkjTu6{z!LgWv#e%()rg8o6soyWfC4xiIXgM8x6W|J* z+s_^+d7z41 zQjT<-#r4O8i0>mRdP8p}nDe-En+|qx&=DU$(JT*ghV17oL^3eG-PLE4q;kGIRK&o! z06C#;G{#JRbb@g^oQ9?3n_5G@<;beSdjKi@o z9LZdeD~x=`@4L)DG+JJA&}bFXd{_Bo<^wD{aX>=e9!oydph_nS3vJ}C1E0eFi~5^W z&twKpXss+7$oE46VtfO>y(Ed0Bv_csi={;uB4}CKBb@mq8M%FIBDtP@sjs!{hb>0$ zW5&Eva#5DFzZBe!a+V#AjN0>8dlMsbsO@xsMoP&d$s?<%-xk%t>b6z=t8^F zPIl;qCPTT?W7!S_SJAbp$4jzX^Rz2!rAyiWgL*5QgVjJjQ8O5JQSL-cMMbs$0t3M< zNG0wV+Hi6A(ODz|x=>V4bP!8EGdnoJ9{exLM_{J{NB7exRuPlm`Qi{8d4do5fUO>Urp;z)ly}CCaGv)$IRy_D#EZ4wZHgy_LotA zlA`_0UFFWmmbH$Yp%vLlbIR>b(I`H<80ljM>bbXsGeot6;&Nvg|YoaEjsM(L)_ZBU9CR1%+*JrphAU^Ow(I{@ndgv@YR_WFm6%X zI07j4U|_nVRb&Hkxg{?ze7t$;;2|U|&pIkh{Bt6H{tsInj5~xBl>3>KtW+Dw0%qta!;X5Fl#oJCsW#f)2Rn|fk+YwHw!=d^i&Ijt&M=fo8x%?M# z=3}4@`7}i=BkP6G&2pNbMDKzjn1&5nXU+VbH(EO7S|RdyX+H^X)aK3^JDR1QR98{@!7fhADDY#qJ5X8>C!rzwlx`uS(!$UV;pkD$0RHEBe9@s=056aKL zNBDi)ovGj9x%h36)3)L8eCiJNgx;Z;MTW5l$ci^Cdig^KQlWge$W?^D2(~3%j6oryRjKk9fA-+ zDpKy00yUlXel2GMBLqz>L_*M*N_T{A4P*y(b5zUuIC8>thq#vGN0>eOD`!?V;}JIV zw)q2>S{_nUBkdAUx+6s%(Yxu6x7ePyqLBYJgIEya8&Z)(D0~rBuvmNk&g%Q02(vDT zh5OF}!xRQFJ`D~fr^}n$sFzV`*dO zE@)y&J$12+D0Za)d&osg<~X>DzfRZj0r zKAodOs|+kYsQO{h9yc!PcbGKN%`k%n(W)GP4n(qYZ4Hi?#|xd_Fqgwi82Oi_KfcGA znJwilO|V7Q|JQ!smY)2F7eJIo&DQ=u^aB3wi_g0L>5KF9EI|Y9lX)t$dRKBCvNDhP zII+IC55127(rQmT3x-wEtEnNelI74SM<{aiM;b@7$taka1*s33rA|&~akGlw?`Ovh z-q)?G^DG<`1!zCklE^RLHXPxz51>`{qv#MN$N?0aK{{%njY~8Ze-sGeuC}-1TmM(Z^`ODVlcfJ zNQ7wF{OVW)fj_J#GNNWxhQcOCWbckk*CF=gb>rM-y3B@dc&G**;m&)LTe4{H>HPao z>!G4Hg!X>;KJ!wv0x`0cC%GDjZKB~2r=BJ*O7E&`-~0M(|Ji9T{|TmxtAy&4#to6X z5BdxO;S{OXVN$2s%vWB2M;HUR*)pm?+?4|=+Nd%eqafbfr9udjS;(QP7H}*BpDHv^ zEnH`JZv1)-z8=Ct+9|q+YH7hhCXD}DEX7JE(2#6zVQ(_{=-X-#@t3XV=&WMyTxXW!ms z|M%T^{ja7YE*&h=`k#WZ|OM0FVGmW0%l0bXY1w-a{+(~*b^ zW`IX0Y6>cV*1!Q8A>)ulm`HMsTlxl6JiKjoeW>n7Nr`XIPz#b@!2`O7zdLg+H>X^$ zkbR$?T2b*uD$JI-@-N(<>UAF(Kf!6v@>wv{6o}`5j{m5tH;Phucb-u{bh3L5&qn_E z=(O{X%ZCJ=T_}&I>J7SC?btWfmr0>j-L8;kRJ3}zq=X%B8L`+E?K@eCPs?P61`jf$ zDGxKOXTKLS%F1nB$m(Jao>L+J2gwrG4C7{}f3L(%w7576mZ8w0g(XRriJvV7S4=|N zN2+X&Co7#v!Jz^32|OUiwCc0Q;+m*IR@Xg4i`9h$X%~La{we2h76hIVt|G) z6uqS$>U*N5%A6(vi-}u}q-sa+MAqWG#W15DhmJl^#4BGd0R7}!UkQlyyhDSBW$wq{ zsXy%VfoCJbfg1^nNfePeF>IT0Zp##f`PI8{M_Qw8R#wX(jlH?AGT4{k z54>x);(oMSD z4SGG8C3(f=T(5DuSoNg{T0*ed${#C}^uNEU_qt(xng+=qfSw}*Jj@q79KZM1`c)X9 zdp$SKsQd`$#Bey_?BA)B39@VMoAbBY|F_G|#0C_4;Htn@mWqy!yOcqNCHUjBBVP;l zX52OVcBhov&NlK783G~eUC{$-MBeXsTrh6n_TtfmojMfu-Hg<3`1#<2S$cLO$osHL zAkS_?ml${v5Ryv>@Y!#pWaz9AvgV7oYIX2^Yy6t^wtW&?TV#4vCXp|6F5zB}H zAG-cQr}_*q>QwK{aoTP-^NgD4Sn^-MbmCg_DFie*D`m|ok-k!BOV2*MVLKM}kVHmv zk0MC5ZgE@D;#!USCN(0;RML#<=vqe~o04xyYJb|F2BPZfx(hOR8yt3OVyt2O2iTMHR3jz$s#7r@sp46-t66@bbLf z5fUnm%T2e@%E&eY+ZJR0ls!@7OnQEsKv(|`^bLjf(rh+=AxWIYpu!&*Sz> zV#&?aFw>cLQ^m_=R&)&WD@*GqmnMsU^>1Vt1~^Z&LWF`mMbej@`6kcTtTUVv9F%IC zDrL5uz>2+E{j|WS1;JNlxt}HOl)VYtGr*mfnq7$o188LWX_6akHQg~GvkY3h-E&M= z%f3 zGZK%3z2jX$i#6~2)GqIIvRDW6(BDWiAg@$M+uHM?zeSK(8=17(R~90}4rj)?J(Mb) zLz75B-}y4|2$g%_-|+c10>tQY%f!#fPfu98Y;M|9zm2;2&_y2|q*5?#qyQm_JXcp9 zdG@SVtI}%t)vG+~#@ld642#~__332w^msya0=q;q98xS5+Y!>GKqhW5LX1r7w#epv za~s!BVvdVjWH0GTCB%sVUY3!G z&6_Xq6!Z!^ZFMyRDuSFMGiy>Qe8%3BY5SSW1~;d3b}-?B6_mn5)$t*qaY5+{U7xYa zOf}7wWgUyg7zi=bvyQ06e4Xa!S!$zu%y!ChI8t2f&}XpPSHzHkr$GM&E0!BT%#kmgEm(eW)erC}WZBm1ff0qpG-R-v0|HB4`;j&T9vU zd&8P$eG$^zJ2*b@EIqW#DJ&)=zYlT$^P>#L%&zzNJPe;C%y0Mp9xias1s_)x`i}?r zmVOwG#>EHE(Wmz;!gD5-rUEx~;E6q+nHBvba@t?5VY)CPzpHTS?IWkK$z!Ps=X0rNycf$Ftn}Ka{-%SXEuuHM~KPl9E(9RHOyzE>RE!mF`9X z0ZAoAN?N6q5)eVUQ@TMwq&t-EE`fiZ`-%5?-tT$8_xrEwzRq>y*=O&))?RClG3Od% z?P;lU=QZJ_KI32r%l6-jfbP&DkP}>{IsYqr)RVMp1Xp`EEVqU4sYu zzS9e2fnbm8PWXBU7Hz=iHynR@UgWZFq@<^Hem+`ZnXfJ>tR1M0(Tk{eSYehtY&A~9 zRj$DH>8A=33}jJL{j8bOyrTJyTUaWG0(*cpgI;DO4fkntslqEFsfYs1mKwP_s`>+g zM_yi^E#e}4nTfEOS9V0j)8ak$wcV29xvUHdhs$u7`K%B5Sx>}xL~#w0YBPQxwBW~)C2}p~U8gH|3-)9& zKX`6wQgv-4y(j-=Jk1^BgD}(np`o&uguQ%=#d1VeD`U6 zUtLEBZnaw$ZNt#xq3QQdZ|t5sH|-~k4R?-fpCS?890-3B1aA&v5N={^5f_ppBJnE54t{k4v_?lavIcJ}dm1WW+bpF$9ce(kIR^pYWP1ein+HPAm-H z8HqJr5v?MAg1k`bS7UQzn|u4Mlco&h@}18we&c_RA+9Kf@D?r54Hh>tMov}Y2h7Nf zbXHmz@VH6MG2IP2aXKKb<-nPJ_i$B&#dLN^`*hu8LgkU(>%JRi?WtwgZC-u2LO7I5 zZg>`NhZN2jj~Wrj$J0dR8noB`T+UxF^1vD)8t(SQMz^>>G_3LQ&y)6=*V5(TPBI=I zVxr^cA&L}c?d2CN?#r7s(*!CiY~_|wP?>iI^AE{$%#5;c$iTS4{y-b_Oa8%hkL?2$ zmRjqhhY9Wx7DI_NxO+=asa(%q=&BP^~`3Vf#qD;R(LdS+Q@G`PWuV(%i0X z@nOO!F%jkQ-MI^1^tF1WXdfaTxb4z-WM_H>XI>8h@3FwOuql%kEc_jIfs=Ehqq8MMmrh~Lf-#YE07&)Ew1Ye-8_dSDzbl`@x?SAN+)^Kpce;qi%fNaZGP5^SBS5K)q!xL5HrYkZwslDRos=!Y^d(N zrI;_uf6>Xw>8=QS!|N6-JGx(AOkbPK7*OOV+x5M_9=dlyQn!J%R9YAAGMHIto+CCu zY3wp8h`>aIUm={ z8AmQJ&uHW!y5hpd#+HT|ynp!PYULH z3+k`No->P=m1T)J7$rz`k~QY_7|8agSn@maJ9*v5T)+EgZ^eX|{L4fNS+8e^C-(Tj zm9^R2e1%T@59gxx=tnX{G?#Eby?PUPN&YOY)7r^()J;70_Q)e6k?G-&auoCZXGOeE z?@7vH6w3UTt%ho_kn*bY@I*1jz_6JEuRk;`Ay@n~^p|WcHSPy-a(J%{=2-W6xYt-e z%FG>m1~iDHvKVpZw;Fo(Wax@7M(@=m_rj}<{!SBOU%z1Ee4;kCzY?U_VYnb^V8XcD zni@c8zu+uHEB4abQX`N&0x7NQHljR>mX@)Q_Hy@HqiQd18e4~iHzsU{o!1oZwv!;<=igOia|4^l9(~DVA7YP^jxDAkK$k}(>&*sm zng)7%ZI-`_GgZ>vIZ`W!c2E0J=+_mPq}#0y-xL(Wm!GQn_}(KXI(kO!WZ#2uxX+1s zyv41~%bZ2|b6N_$1Yu1*1GdgLjB3|HQyI86scyH-(;y;5^Tkl_vupym>HE7MefmA5 zNq;XG4JAKb9gW0BhAHU+LbvQSs~J?&xF@o5>faPUHvJMj%|ChJ(P`u(VSdFl!PDkj zx_@{m=H5NLzr9~yyd*^wLis8yhI3X{JJTm8(&UDrRo)-Dv!BLVljAt=s}bbuP3iBk zyx}ELMNXVIsD!RuVTp-E_1pP}@>x+h#c3tc*=o-E7Xvo9Du);Vt4 z>l-e+p5bwEyN0Ocl`1Yh@@=yLPw?gAK--!l0vH~&;_jcfXg%gy+1h$!Wb|m_b#v8xT-i5+XhbVpH*=Ft!EAqHvK`~6fFQp7p5>_LLaZgfllcgG3{U_sR{@<8O1k z^dWeuIS{iF<&am1G#@D$Ch-jxxONFcZ= z1M0)p zF1AHcQH0kSbxRH3t0M<~5j?z@W)}QceD!i&9AyQ8cz7Rd^iJMdosX=t%jW67hBgM9 zPqesMeoNw}y^DwhdWgK6{yp2E-@lo53qe+1y@u;c+505p!(s;`2U%IuHP?!B1f%<7 zj*7W=2|i5pPcXyj^0aZR#FLKdjGh_2`GGkD@rcp$ z@~{(&T9&X_x@#lM7>E!MI}8WM?weI)46AM86G%yU(mu_~DlndCwN9dK4H4SOe<{Pr zo}E4XG@TDLwoFRDlO(VtwYa}?u-|GQ!$Rzi8eSlh!?V}04X(cSuM~#!ZEn_I_||Q6 zq}umogg97}>NyY3v;;3{GF~FrZ_Bap<>jOzmdlDmO}t5PUd#GMjHRvB4>kN=88_(Z zFG((#J)siSj3(GwpV)A!4A;SY*7NpoT%XVBWFEWkwaDf3{;x8Wd>wK-__FCzC{_P; zJeGC-n}Myf<7d$l`wQcmeJ%jh6=%r8|%!m z1O}8h?qe5udTgfOO8t@E6YTHoXNd-|1~7QL7!O0rk&)T^dA42^rtvMB_pCfITNgjkVesC4~SY?*VVGJ_4fChT52E>2&>TocedUXYzQDN zrorVI>KmKp@BCV7%n)Ez>n@lS{tJ^ua@ z{B2WN%T{n*7}qP|N{CwtA5u5k&r@;PXp}Myso9h!{ByjL=lbXHO|o7u-hxB7V@I&t zua{^KANkr52{ypfZ%l|Eh!%4`BqAZZXKx?3>SS7VU3tHOR6@c}x5-8-Av_d+PY}L7 z6Xw;NA-v;4GTeP^?W6`bcD@-PfgrFyXn?;z*2VoV9(RL+3vP)MxO9k|iFEwDQ7A!q z#w$Zy!ANfV;6rAiZqZ|0qo2;vmS3lzCw4r~>Fr&6>DKYs_2hxA2D-D8I6s8HXZFm# z_r{Ej%rGJ1M<0bLUKpiW{NAIk(aeIm3AifRljtSuyszQ9R)Vv+dr4AKGPcFbMhLqe zP<2pNeg*)}`Fra~bGeTL^%FP9hrpIL_=Qe3eUa=dYW-{*B5F(9Ph{_{2K zkq4v2Sz4@e`){~nldMuw?e>1kC7kH?MQFTF7<3msdLi7of6yML7pO}d3_%KoW|#6y zd%x1ek<8lvdikosp+ZCF^(u)$K0mdH``xcLqouVuJ)cT?f+~BgT$)%giIB6yRtZKC z5i*B)45MkuID!(jh@=1rKZackjZ)Kr5bVSyTqTpg?{&s#eFlsOh$5arLBbGkpB#jJ zT!=T{y+ue#Bs0p`-OCV-uZXK=g_9G((;(9tK<|jNCFg9BH3!+pf!y3I^6U0;Bmk;U%Uc6X!puazwyNUIP zZf$7ylJ-e9k|~ilL7XanslK`S3Mc3CLVOFYQoKycBg1!wt2xE}ek z9G{8!l@|L~I@^18l*v8R4T0_vd~Yv()56xuWAf_guzOIj7$97C%NU{2D2@S*S`7=j-w1?Ou|ZtgMCrWmH3W zcKjm2IAroNdI=5ZwZ@G@Be})*_*b{^35>)wHTNGCI~b5ZiT+mzZl|SK^5evo=b{aN zV+dSvqYL}lP|Agc1!h~iv@`ZLjYJFUoSwZW@*0jgNpAYvUi1~7zqd@^j3E)lOzU>H1d8xhM&+_CT7|iRy^5N(w!Yw26SkU) zbY)h2p}4f4&I5UPqs6F+Rh<;Ij*jywar$~;v!5U0{de>(G^wo+Vo+u;_B+XP`#1RR z$|cM%eKqDv2&8-Fml~T<*8(q{AfO#_B|SAnfu`?Ct!ITpe}nHqnJ+(67PxNRn6Gm% z5`~-f>on$WACu>=sI|gsadoA?!6dT+!>H7{^Ra|insEPK#r5AOseCYLc9X8)+8Z%I zG%oj8A|c1rQ=62&c#uKqf?0% zpTc_-T#{Rhln=W#|CTmybqhV!r;o7dinzoiWb!&$ zPsJOB`{_otr>2zwUoXqb8}AniUHm4BYi`~#R&nV+;eMKu1^Q)Qh7bHUJmy_LJxK(& zs4q(jHO=xs39G)_D0aR9qZgCq=C?OP`VRfN5h^%1hIp&g{!6<`L_XZieUsECmK2qI z#i)&w-~aul|90#D!zT*q2)z!)&Q7Mzua1Ut%KcB7udT}~tGLp8ud#nMun0&Wtd5X` zh7xWbZjpRDG`5wgN_FX8h_5FKFrx9KKK@%0TuAz+G2!uz54P4Btr}Wd<%edRoz4p| zFK^bIy*wyL9ZUS?#H(nC+4fULId;|WN#6xBp3fx#-7h_T%|m$5-H+xx4>!ELnz!dx ze*D<1`?0fP~2r98<)PT1e+3apWtPaM9X( z5GX-n2ZIp%eW}U$dcw=EWxQjOmMttylDxdM7YXN?*j5xx%{*{k8M1uuI(ho-0XygG zwIBOl|2c(bx3w*L9ONK00(@Aq;xp;9Ut-(?JqEX!KCIjYE^U}HMEZH11eI@B5u_Op zeUU}#S`GzKiT*PF8&ZK9IQlpM)UFcj5QK z5JZj_eH6g$vVQ3jwPoMZKHm0DzODS_>?3zU zY-)h4h6OA_1c4;?&F2UF`GH@Pa|v)dQ|y6v_zCm;1M%r#=4YTFzasR&kT{5yU~QtY zspn&l{68|}uS69SIoY!Pt16Gx9)GG?r%)HL$TE|Kng%1E95%9f-06lpB)HXAQmvb0 zUlHbFGl_v*9h)r7b1cchOW67o*j*2gCrmX}m-}gC@ZelUaqKbUQ#EP~raPZ9Q_%6y zPk{n7x$*jpQ1=DIl|q2Vf&r3Z9x!{q1t+cTvl-{ee}Q!W9Rd{aY&&(g{FMQ3iE>$+ z$cRB}fSIyJ9qliZK_Z1u+j9vD0#9vDtG@W3iq>iG(Ou~dD_u*Vt0v7&ozVOZya)@~ z+)v6aSmQWR&=}hWMn%N6zmyv3s5>M)whcGkn1T7@hu!;mNPOQ->1W)V6jvCkh|x>b zus#$Xi{ihB)<(`KNQFvwJ8u6U`R>RUVL_46(wLn`;y(S+(H?18HZRI0=wW>2LlhKE zcP;zcT`DS(vO|w{+rFGk|HVbZD=7ojdBdXN$Lj-w8KLM^OcS@31d~PXh+qwqS!UD{ zBDikfmce9s`uxiC`3grm&KiMw-mo;(^GGIZcf;`$|* z2i?W1a-XJ^Ui;5Es*bGXlh-nn;ju@k8n2FC=#gr#w!8!|cLq*&Szs5ig{lvq0sn6j z3UcG-%5ccPr*ij~N1dH)tn+L<2EJNR1%~-C))6q@c4q(vVI9|2yj1uN-+^4tc z{smOGh{@H};YidOfY8bbjdItGUc}KUV{nub#NFP}6oUq5C+-)QaoEJj8 znJ;Q{n)~FJ|N7L%SVGG4jEFIMOa6kJ_s~G{$I&q}uQ!h%fuk>;UFu8scTU!kL1zhg zPvUa|b89z9}`b zwGCE^o5f$dn0x`U%fMzc7S5Zm6J$;Z<*wZi&I}r}e8&$ZTxt2ch>js-kQ0zW-Vf(f zBu_oEe+_A@qi-o}=4w@ifBA$oP+N1i`85_H{+hr3rXvjw^Z@RgsE`EnucTN9 z%tfU)Y$vQOW3ZW;0Y++%mtkA-Ju*?xKNws!!(%_zDX+mSjc{9L29ko9Ug-rwQ#a=I zvTCKqz8Y0Bj4pI2fA3JW!i=p3mpVRDYMwejyMoFJL#c-98dCC-^>~B_^EAVr-@A#b zTtFFvo|7H5@%-+?GE{lBFvJ0sAI1pdNR!>U>5X2m($vb;=#QVo2mpYMA!GC`R|}(g zmS>n?V9l*AF_&ous+=%9?SZjDWI&ZGpzyHR3?d!f5TDK$xNqM&5)-dTo_s~Tp-_ve*8-*a!5&qb zMaNgN;of~k_mGDIZXfXmuHI9fLCqypqWtB#nQXa_CR8(#cj9kx-ux}yt6Rwtb*zO% zQ!nl^Sz=csA?J>ax$D`Ae2*G8|^TbYvwND_(8Jf6L10U2p9pW;$lcz75 zFec;31&rZ0lxQ$v_0&=?Q89RgJVm`(irW|iGM{|d$`n;@)*{6LY#pxIKwfSo*4~Hc zhuWM9;`$HZ|0y4vCfe`ILZ!p+m}+^$WMz~Y9%F%7o{H2T?INJ$sQeO{{%=)OR2ku4 zs;H_8YeEn+W)w{z?+k;Kl{NL2p5=!}F|E8ZQ00PBhq3A=Vim`Kkosl6`j#^_QrFNC zB)1N=03VJ9!COz!R_~X`$mz4gUH^a@Boq*1vF#zMwV$~ekkb_4i#Rx z!=IGbk}K@1C3EocIz6*JbCuU!vd3-3|FwkrXk@ZZtH?;gU65*@0-o#A$3uP&pKp-) zX?`i;?!{kAi@W<>LzpEsf(xCV z>K&T*UnSLt2`j>IDAp?`D5A{FEVVP%L4kw)qg7W+u*b1waeYo@2d`@zQOTU1d{wOrvf-u$>zV)@^^Fxyf?5Ojwj07obBjI=g=ij<#v2e@s7jI&k{-k`RrF=*h%d! z^Py&_uI6wie(u?K$F~ie%Cy0rJaJHbOf&kzIx{;zA!slckk{1}T8%L+buXvs5uPnH zZ>W~u#GkpSIaUYtEf`bQrDc!pvqZfYENyI+P!EEKoOq zl+CTosQmez58>4Rg`&puJtnFTZJhY#<>lDA(D=vmT74wFN-^glIYmeVlEx#RPvz23 zf_ox9Bm_3F7e6j2*+;7zETJo$ayaGnRlX?jGQ^pzya)&1pv~Vb8L#1%%}aV9O@>QX zwZgEKi=Al{)G7r4>wURGrC@;sIFRe%G@8Cf(I;+)Gjx7Zh_#YzR1lhmJD$LOKo-b4 zAo8E`xRe_&*pIbPbv;z28+hYHhXU%P(Er)rR|Dm~n=9YB_{YMZ(NU`Wq2B1Ha8K(T z4^iOTV z7INM1oVhJvaYN!W^C*@JvuF2*+cH(siwOQJB?Jbm^%mkP9!bq4;37|iS|-FHg3KAh zgL&{pp#I!vHa+|sV@%j4uQ2ix+Pns`vUn_1nW4mUyO;{2ckYuM5ekfh5FHgQeSg9R z)PiP8oT{2VRcYSSvpaabCG=>KjqPv74${UN4oj@2E+FF*=kh(Y#1oh|UWu))*`TnM z301k(DJxVuz^7#=smZPB3ZTc9yNlqrjh5)plgC^^oz(arSbV3A0oZfZuVyUiF`Cd~k3T^}_zf1sHriU&RSAWo(kk_ykL!*4emL674at3siy3j&aY@Ar6%h?&-2I;%>+2Iv_iR| zoF1+JLhZ@K#%2=iY)VKgdb^m7rX|XuB7u?mnn11G{M*xfC$MaBVLLY_^hb+1ut&Yo z_WLZmbUgg#{Vn@tgUyEq!C{Uc7F!~amsprP%p4s0fW=cQC zeMFE)=svfswy-D{meLBxAAKc#;^xvYD1{*9FV=z{ey~r}cO7db6uVh3lzj1+HJ68_G5G*hX_NHDN>O zg>Ip>zrhI%(aC!l>sg@@>_z)P{#lFaw{>v>l4_5r4$&wDd(IV%Tz$dd%;z{QwY-?` z&n_cwuXt-}KL*7ATEZ>m-+0g}sUwp1^4U50R%n7C4ifRgRN_llY#O~Y1Bv_v?@?jx z9XUO(Ku%qY1AXy>2fBg|xN38k2Pe<3tR&mzt!NVNi=x^hq^LeUhc=iau9&j(30vjC z?&^`e`&P3!yw1tH#|slCPv2~krw8;I?oYp}-l>m-I#_7WdBbLR7Xn818ho-gxAA;p z42YiTSNSx}y=8wPf@%q~Y`2V;mH4+mK6Yg(@p^6(9@OyvKZXjE6i!+RaX%dHmCZB# z%HQ5Hls`D9K|Pr9pO>MxGR`!}E;BC7q-QGJp3Hk)k(TC@qyIETpq)?vL0+!~)HmC( zdoQG9aO51NM4V+N98eYw0TwGja^n6c6JNu3GU3oF!}b{ z{=VjUo8nI>Y_ETt|1?vBZvIZ5cXizr)EMSxr~DwlZ*n~Dmro(cG}Bj&OZ*U;@y9h? zngJKXn~>ky_K8tpda3hO)W{^XZ&E$98Q88p3|WO)U08G;;iq{7q-l0^^I} zVej#fR2arAQ8$nGsqW)nlH*Fe0v+@-GbjDw&0y1jGx8iW+7Uu%Rpe+2fl^JIdne?!9I|(pq3;^~R`2^2*3SgbtR7#a<>#kwur2IJ7B&*Ri-d+GR-2;VI7dH+35|un z*LJ>qiR5M&*4{zX@?jqvzwnq%bVr!^_BNg|)HEDKhXXj_x zB^;bTcRVC6!Chk#$FEWa#(d}Fdbv2O*m*X+PIKN(Iym?$uj9BXty`5ksCFDk84o@bLmBzQJ6j43d8GQM5AQ!BC8+Fz&Q z$HPUKZ%%S5A`ylftvj!NU30)YtfBM&&U8}Xsf7{z%`-QrB?L2AEw{h)xAT)*$cGU+ag6+#1Jg!>$$5DzAarUIuuI8+rU0Q`cIo#*EI-!BX7^M%wO-4hs zAUF^@C@TIorQ)*XXS(jmR`t^yMk6p&4uvmxz)0a}AEJ$iv~A=HM< z(3RX}#gDbw87jpgee63hzhs6E=_94>;O$q_N{F}Yb69mNS2OgWev|j4hssFTPJVXb zcOWWt^#AwN@z&+?JWFDXWHC~B7p+mD3WlX2gx65~TfvvsF7|dEE-7RB4{zhI-6;zI zzyXN}Z!w;%Kw4g(%J5j9fchvN&L=djvS(?@dQdV%VbWi#kM>z9!a#I-_=8du@h zG5-ozBB#_r3Pj$kSz!|DlP#(8;rMF`F>w02ONCUKF}6NL81h>#n8W2f9Y3Bq;#pF` z+tB{8V*0mr)Bo}F6YXImoo6`c^lJT`Q#-dFsa=!49@a85;njDX2=lV$aFq`GgP`_; zn^3vi9El9EFRLX}G40|=(T%HAbkkeEZn$nWPx?Ej%alLoqyPYQKD0miJ7%%#rA^q~ zNaG1`IS;+&`mXhd1*5Y%9r5vNMoF>F?atH9mAsyO_aoukw?(;&MP8;$KlJ(Cgz-o7 zMOC8>p8y{k@&yCKWN?;{ySq=(W@KohwZ4-p@RXN1VMN~d48k%Yh#0voBk&80Y8yWA zTHLaZrTJ$mF*N6YgeG=*Tffg30(p8yZ<;C!yK`+>`e)`Sd<$$J+yS6P#q_}&bafUY zrRc~z5|W7cSnsy-J*XkZxRo3(Y&pWF}%szoVLBw12^j8n40X9-j*~ zSedUqJz4zvIH+^s>5;TQrF;oR&hn%XoX&d_0jH{K##|9s874B`6i`M{Oy52*>4H`oTY(ls2z4dc=tB0LPUzA0utTYx)=H!zcVWe>%f z(bMx&EsHqa&o~JGYqg}X?Dl!ZdGbxb7l71M>4yy+#}HCuzH=aL{!OF%39Mtkram+r zn@(pH!ZxgKH{(duQyuraIf9V3LuOS6P!4Q%1UXjb?(3kcr` zX^10=B9`l@hM7MhIiaBswyt0)W!t4mHb6lm+!v*#ReC7)|wZzA%E*80Pw>81H(dhIS>RTMP?h+x?1jM|cXTS?w+&kujubmO4}R zr8@vQ-xf8Y`|VE3SazHa?2j#JdzDdFa-4Yv$cJxMTV2k}6#c0-)H=4UOJ3~7pSw>4 zhpy^=j&u;Ei7IPaJV-x-=ai{34;;%CSP+UPCD64d*YbgihCyOnx0c;U&QxMZ-K52* zS$HF<>g9~$i_@xH~?B4};LAtVYmC(3$s_N6-eZ!*cAr|cxF z88S86s#8*>9M#ZWd_d-}B1Nxg%?hZcT%MYX;2G59U4~hb+Ji3U$1!YJR#W^TY7+8%Ta?Qq0L85b9dv zx;IK?GTZ}?u~Ph_xC=u=bdEI8FBrnW;NFpoR1%>|miyVMp%@sxXh;~H#w5AXrQ#9~ z$hpMBng~A#UPZJg1mt5cj=w!jSEl*-M8oH%^uOy~C~W7%EWNlDgQ+6KxI;k2TDbrjQw z`^mp&Nq9w$OVZzt<}GEajEdnnqJr{Ea?NIxrs+vCsyM%9&J)Gu<~VLi%Urm$7NiQi z$ME6^{y?Vyym)+eO5!w-(ER#E?P|Y@Edn9stMR zYPL9+Y-{Qjp2{M;eKa1;PdtG&ik*X{@8LvLb9RhNE8*!wVX$gr>9X4ZDH5frKyhRE z5Ld+!2Lus|Cv0o}A~&OizMa_O3Q53Iq!h8g3}H5EZv9$dsU-0y+RdFf+^@w~I%D!= zWEwBXRP{ZB*n3y`eqDG}naH@h!+UnZjYx09@7jN)vR-&X)Ig;(s39;qnQ(WkDz)*H zDTE5xv$rHS>DE?fzENX(TgUNbC~U^Y9Spc8Y3g328Y}f66oeEImAhl|$hQ zuzgKseaY*l06tRl#9D>dbH5d>P-PQ9R1WcQ<3UA1;OP0{M~L`X0U3hA9zaCdmK=?n z_iM+qnkB;1nIOzVijvMV@C&lHg+k@&Wxt{f))>P-B9FM2xX^&A|2oNf(I%9bYnWB? zS$R@w!{k#AO`<|ttjHe6Z?CA=-a<33FTD8_aj$vtlx9vn*{-Ozc~>@3XuH(Eo{J^t z{nEHMMd-iWAvVIz@akfX2+r})vq5)=3Mht_E+LVv@+E(o6`I6yNjl;mS^&+;4#;<0 zhllHO7ht+TKn0T42#Ko;TgOPr-yj^@gX83DvDcqZ5jZfP#CZtVESg0CI-qk&=A)w_ zb&}qz&5R%tB7`I)Wy_Q1I|m1u_1-&0-Q2MB2XP}kTzdmH*xBt1`=$31qdgQI#lUIK z1a-*8NdE{ZMAH7p6b3OBvLO+l=wy_Z+<$oHn(ia_3W?nAAN=pxiz3nA!A0fyFCAvi za8ol>ssWu2V@bg*sOI$IqYE;Kg(nnq|hxtLmeSnj&po7 zEBiCH+T1(k13#Uas8;j*obEmANv=4q5PZBlM)hj2$)2JO#^J8OtreEtf$I(VpbK%J zz1ALPusLNR!f*FB5Ky%1!$Xo;yBhh9J|EvvJ z1~ek}N&yC>r+;O;c1f>;J-v=K(tcKdbmxB@hutWJKHPPWV!efXVQBc<@b|!Li1!)4 zJ7RdUkSOZ;`8KIK7DfAS>Id==QI1c)N_@HK&l>px1|%x+Mim4WTJ0BYyDY@*zL`El zn>uJjKN% zZPpmn(|)=2{c&_$ktwE*-dRQIRpJ61RO%Y}f!~Cj@z-D@B<8?6P`nDrmx6;;B}sH$DTci6uT?8f-eM|W93J!U zEbg0VN=lR>dfEsq$w0A;l0V)*NOpmNlQ`8tLip(v>EcgxY^2eju+ie{F8<||9ar1T zXRX%66BPgNHL{;K(JhU7DG2Gr*DpPy2`Xy53l_JgwQXFPDr0WO##Ex;6d@m%ucj8u z$C4*7dgs*$W1k-ng-1LbWUnr=Z!|Ay_^#KNZdkjrhIuY2p{cnnaW1#N#H&!_{8`Lr zkrZurCobs5gOB-72X44-P8(Uo-o7V3=91rNjsP0Oik@**4I$NEa$H|mg(XGj= z(X%eKA|13+NCNXemZ!FUY1uha6F%sHSGoH>`IFu$h22fSM0|zT4~$VnS7|QD1M;Y?wo@QFz9wbCi zUM8O4OX1Q=PWU@&li{yV2yaQus<)&r2F$9Dor6~{Ui?Fv>4T`AW%m%BD}-<qD^X$%0FU;4{k?pYOGfSHRI{2zEb|8Su!VLDhgu z(bI!|4jH^*jUeCwL}z1T+g+c~Ny~u;gt`Ic?y%t+2P*I)UR}ZR{+-|!;jHNk{yT55 z@x@;2j9Q*7@VHTGGf4nHM0p51f)sr36WTbjRB`iABb(rD!tB-X6Q;YNw2y(fAFR1# zvmfxf3~(nn^UF%2Hs zRLd(X@N!WLcv|U&`VEdt+v)X-xZyY4+JF1aI={g57asmKI)Dztsaan;tg=#gH|u@E zH<2|ly6c|E<8HA8@ei1A9q`)K!O2cMtoAPSG&_PRDSOhBL-|j&!HwV`u=-JgiOFtl z>bxesbOHu%EZ+@M@SACth!U7gQKuH_T8UTcR5CE~P=hzjx{U*t$z)B-rIYL|`)HN; zonEa_y>Q!?G3(KaDz~r6*iN8Fm`Ej^`X3MFx0%9FLnLQHrNPVSl)D0+Pv|<7DGyvx zB>e7F3CkNh59vbr!6P{{n@PzrHbVGhkCv(iA7u}O!-#0^X;kKhhNn(aVoC<;ywkCK z`IGrTWUqLT)?;5pYLO~+4BMl2T^wQg9`jF6AJ@$Cp(Z$Q{SvBa*c#h~BI!bD6 zu*$9~k*915BncL8ZSlnnp?ryo6x*9N^)4tYtQBWRqk+v>YN(RU%<()l&^#|AAKTk{ zJ%Zk`d6BUW9Jlq&1Af(iI!#QVmV1#99pMky1rMJe!w38+|E^?@@~0dyPhEF6iI2ba zV`cp^A+z2sS`J7i=vf%2_Q&^IVHr|iS+KSv*=CE*#qc6Bxc8ZzgJXo-@!29+5Y*2# zL(@7_2fERoL$QR2xPl1flTBe)@~E3QUw_PHJ@zeOFR7A)1tuI-T;0y%ep~%xQi`bQ zD2W^rpHK>1qoT$;uk^5DdiBJn0AJ zwJY!7=}OD)?HvUNYLj;P2TtIdSP{~TT=qK5X8>7t>6KBkl-IW^8SFGHz=^7J2}vZx z2mhXr5%tp>ErYZT#q?;arYI-2*Ah=$QXDv6uPg089#-y4;4a-K`r_An=@pK&!4INg zNA610LKn8p(zeWWp^jPX$(g%Ml~gQI3111Nc=n#sg0KCLG|V6EPljzJmiQHFo!8tD zcx}x2;s{d%DW%X)90CHHv1}zruF73%L!Fr&uQ5?wy^LgLDWWg;W5N)TI6&`3$BPV7 zu{;F@!CNgV%E`#Hg@vUxe~f%gXM)Df zGSX3J8KUJ3mMXu%X?mHeWQzY^g~PY1|7BQMar-aBg7DPXJHB4k! z$*n1+Xz>^5$Y#=&AP_UVf`1`u4q65pMo{HrBP`|F@gaXe`#7^EK~wFT$=m+b7*^21 zP6dKsEKodvQLGizZrW2*i8W8HGvvo-@1lO&(18nr6O_UNbnT*iBb$|16OOcQ@rUrE zyU+MhM8vtFRa8(+|GrJ_aq?98j=r|Gzs6e0Ic+|uPEcZg5WK|SRg?ok16+!z)D?#W z0pT4k=K=#t*O&cm#Zo{$09tzerIOULI0LM6=dY-+{=*H?2ues$1ZO;q1ue(4tTro3 zgGaUT>Z@nR5(5;l;?HI&gY7`PV>r4?k9{YkZa|R&)HiDN>fo|tWqJb^LW0SEu@F9x zS_P|M``2>e$0Zx%p>(9n`0;!JBUI<5H!erZnShQ3JaS02>o@fim4~|7rFpma#E|$u zqBI}|@a4pAjhB17dceEDWvFWXubR%ty&dow=JP)QJ0l1)SI6Eq9m57BjP3_rYG6u0 zLx^f_U+nvWRe1Juey4FS-94%OJhx=OY=6As>Q+N0~RGBsXqf~_F?9HqVnX@v%to~c>4xdloaBv?^i2hI=>6=Lrt`q*x7YsUHA8;fGpu@RTZ z>D4~baa*UZ6s0n0LPl2;OAPuP zV!}6_Uhud0tKu12)K?pB(`Ej517v;mIQ2j6j}c144*#@Xto^HW2i%Voq5sW#F<8FX z8@+FDITmnS`5<}K_EZ40|4Tv?;C+1c+=Gj&T(@@9wNQ5B0j-33VejZddd}kZ_}Gqh zr3kY(O!2@2Nzh@Ywk9cyD!xx_6g5m(} z*>;@UQbSA&5+TQp3{zjeK{;A?8YTWVI!I^s^0#p>1l^)borM>KTA-^%xtYQ>a(uy- z;64oxRTk_HAp0Ta^`Z^F2)4vq60F);M(R&`cPJ7`NTw^e6T7+_8q98Kubn<>E@;Hs zGENfD$jLQt?0_+i6tBprdGO6TX@nl273N5QJs8eqe=^9SxntHS1>LTmeL`8fzn^`s zy$Kjha>XTdL3#lXOEUW$f&?HapcG*cOU$dg9!CcU={2hhTKy+iCFpdd z;V=;D24BAY7VN3W9L3~#!(|Z4AV>^X{7@#R8u)Zk$KX3Tp=4mc7HZ9P_==aHX=yf7 z;8l@o7_>oYenCb%D1j2idV!Xv=_lWoWk!|^{0?-qFZBdf4vx^g!Hqe*)79RF0u6|^ z8s!EP7HQQz6QtZ7*>#Ku+uxsC zTe^&5qTXQf5959@_D_QQYgRJ8p2yn`yhnT9Zea~P;Suw`{ykM zwAJ2JtdLDAPn#6bvxC<3?Kh{Q!2^_zwEV^u_}i&wh*FD$#;>;fQVUGoF26U={8XR$ zdd~RK9`<;7HpANxH)n&Z@z=s81(O^u%24||9d0cN8WmDZZf!krxO`pjgWw3>!zYi@N0@{O zQ%UP2{>%^_)}EhuY1X*Y@ITYNfEQfj@BDS*@8-)a0cPiNL?KBOjlMH>e?RgoYbV_O zaKRP{j36++y-n-P&dFUTN&+~~|85Tg(2UIT(BVBQ;U;y({yntbQ|JX0=lB7e*t|_R zCF_nC3Wfz|!+)x9ZB7on#q{JG0QW#h2D=Hc0-XQ}cf<32bRGt_^7z(w!44>eVSzA; z_w-ufK0Lrtg*go~qrmDB>{$eAQ4(o2u6gJ{6l;Ou3A6t3muFxhv_B3uLHSZ3?!^9H zd;O&EHOd4CFxLb-9AN5XzS~EU5ftJ9t?GQmb}WT z7K6~}8t%{v;p}^urY|Ucb4ukW)BolWF?NB^g33AtzaIZUn0?JYTKjBbFt0YSfB(dhJ0E)*%;m^PpYsQ9UR&gB1s5Wz2i;& z&&`e;p4=K!?9UVg!iU;@gB;_SiBB#N++S#I+vCq*lLIMc;ECl9C;Y)I2+LeTwc7## z>ahuqD6U1Uf}ck5>~!V}2gp7P7JX9-l4(JS31*gaFSi9+GPUZn^MfrHZJwt%fl`gG zSEQ^l@Y*AoSKV>m60s4<5h=;360+(y_UE?=Mn}c`Hl;KVBJ#IWkb4+DMS%Uv6Z5j2 zDaJyqWMoJ4SeHJL#omjdJ;^AF4^=%9{J%kF7=^Xa+U{Z z?8WHm>){s^@hWsv{My|zxt}5U@!7=e>FG`RxAmvbjW1Kv)?4Qn&@^l;l=A$HyvN1V zHWuu%HlK%o@t{4qQWY%&?-PHL=-gu9O{qTn*MLDIwh*XKwH3oH5=r&NH6(%l-oU z+AHRqzxiL+T60lV2vQCqOIPSFx}x~z#YobMwTFh(Q$$8VER}~=Q9rb@FYTS zGlxRZeT~|rN4JgSM|WUr?@`ZP0gRF8K+4hUcgxSG`g#H_LU`$zJ+viMW-a}uJL5S% zBqnb5B#1`+w1P_OJI&*yA+zU zY5n>&>ZA&fK_I3JrV+IAWb40bvXR$^aL7cdMH4=7MqVd3TTiwd>Xuye^A`(OPDt|I zM^@vdJZQFuM;YRZz2$6%LH zTDeP^gzxE4o6iUT`tw=we5}#40j4#-;bXLL?vmrLKq9aL3Nx!?QOfH=h z|B_DXyu=n(jf)6<)I;$!1ci;<3qQzO_`ay?#PT~lZ*GuGw|=}L#VKx(1Rag!a*MRM2(MT#%u!C^7F#lXRt-D!y<858JsdNkb@<~ol| zYn8(MDrIbZy`3{_>hs(kF3&}I{()ipC4BKH1fHHqf>sOpDJ!nz4)ax)DDUD3o=(gA zAwI$A!#V$I;m`mvlo_@UX4=UUY+tS+=~`1$cjkd zxxZ9vRQ{laQSdINVpV14XagSZzOH@BkBhY`7YNCT8z1MtWPgYf?`8KJ@2QY;KT7?P zzE6m)F1>H#WAF z*Di+uec=`*<(KWcyaPhdq}HO5YgL#m3ju{U?6Bj7v*!IGqoTLdqqGk9R+%l&5kI$H z4+R%jQzu!eeCV7+TCeajqGOnHNBYS1gD-he_BE<0^COfHD=TrvYn*#5N`W3Je}-b zHwzj&5s9CC;QCdgvu$@r6&CLA2Cp2jJe!!5n5r7{XY*0}6% zA*$<^Rcej|-V-2BPb|19TSjQ5E!pH0lc0W83~vJs32XWC-@`W7b4Ch?KHALE=jXM| zJmZ516N{H@G!B*Jzq?I5y$QZOoTu}?wQ0PB8c)Pd|L0?OefK)IRZ57y8;O+3NS3)5 zFvfTzlM{OkIarurQ7!S0(=ZTS@e4GKfgiWO3%Z_=!~Wd#=Vb$9j_t}uuO>fSZ2w~iLt50-V%L`DW+i}$fVSdp z?ad6{nOle`7emQD_t~=!BtVe=hRHn{MG;@7 zVAh7rEpv-bmuD{+o>Fg&EkkAyINV-V`EePOaHe z0_Nt!_O_Dl^MKv;T6lx6VduJc^4`JRoDq_%5ru3eW`n+1F)(q4x4O3x#{8}FLc1w^ z|1@od-nO}xNtOr*^Zn4#BI77G*WXXG_R0F@+m^o#dp0d4orXZf#`?I9eF93%v29kA zF^thI(EH{295bHsU4-v zl-)SCC^L`rq8FBzrg43vM8@pFQC=DBc#t7hc1uBnKJEUc>AUUa!#Fpwi6yR2xbDlp ziI*U}CjcyHjJio@_d@`~zqTn$^)X1jPw*UGB6z@4HMNxL)JqE7hPGs^TpvnH)wD9d zN*Eb^t&nGs!~KU9F;EmB)mq81UhXmtTAdpF)usR8^G`VD3qwBgn>XzN zG8voO4y10hJzK;#h_Lj9k3J)UsfDOtANG{nuh&+{{|N_5P1l<~luH%>a%`qe#-O-7 zd1D|FGcfcgD;2{asA0kf}EAnh$qcl1h@xCAnNzV5A7@ zidQ#W9-@Kc9nKG!)ATJxHB4*T+d2GXm-lH4Ab!Uz@=-v(GqX7T6hb6};9bwTU)TtU z!5@0&3gupEISxJz1D* z7RKV%R%XA0GSl_|p)$}D9fv>1ugq}`=4>MfzpJJp4fFSPTswbY`2`m8bXn69Zwt_#7jZ~LzBqVX2^F1?o8q&@V z0?pSUb!k@`v0qVbB;u||2WIQ0awp)6#6$Ti<|4*DR?O=sSn!{auGR0xQNqYPnH^(_ zHaZ0%!G1DKu(FVanX2dv@aeSI>Yh;)A8BbhD(j-^vt1^o8irrjGiK3SU3YQkG_M0z zHRlJzTR@*IoPZ3tRSHo4X+qR!j&0+-iN4ZU?y1F4G z_UJHi*kOQRf79m%z#t{UZ1>$78_eF=Nbs9azfW4hvi8t`@n@FO#?cG2>Y&xf?xpym5o+o?=(3SFQzc1s^>~V_DfhN ztafvT|8tE@VRj6ilA}|Z{@U%To{+xdEj7)H%-+@ zk08d-0JnbZ&P$AytL473yAA(~9t>!DM4Z><2L1%oUXW2*rE3H5N+;yU!3B53yX z4Mn_5dP;tr21Yu+05wD*IiXW+F5qt2D>x5P*JHb1vFMLE+}xvPITi?#>xx-O(U;Sm z?HjVPa(HpU@HmDx6x5;_s&}t^eK||aeBD_=;?UB6$1J))?g668&*w;(^d+{hECTTl zfRN?6@TCUG9)5_3HyDYFlVU8PT9aGphDUt^jJULZ9rkVcDW?@j?QbOD67=|^XWUPh zCLnR$`n|4RXy0n40Sh5Lw>~FysTDi~t$ExI#K`=eKkj5%%T*QQyoiOZTOR>UgiWz! zwJdgaE#hw!5D4p{X|IE>yEad>6`y}|x?+7PhceQoTz~@Haz?#%Cc7qyVJ}y{fEZWl znc9#(oz^iJSoMu04y1I1Yy0Qu!StW29hf0V5y9uG840<$m%LE@SORVeMg)-eqJeTS z8{)9`o-C$EP+W_<4oji}!^om44w>jz3pIM#4h@DCAZy~g^+Bfu2SHDbq(+Q54MI`< z!Sqbv&%cBoYH}1a<$<@e{klQ#YzUv^g$NZHfG{Vg9c>WIM(?PvS8tdlZ*|$UJlL`c zn{P?bO-3?VCJ0_bT$*jEr66XwG>NiHaYk&bhyqNz>LZf>knbGKmfP=hBhL6ILihML zA8XT;T06!M&RNjT-S#oHKE5T7AFFR$qVn&7M0`>C79Yl^y#J@?=IYR);5?URXL=)Ku)9l!GUGU!6>`{X|lTp005fb9wur3+Mz4#Bnh7 zY9E2p@gM#&lMG=?R@)|5V(}X+jvoV;u-wHpPmbY zxL!=*_soj~ar)iS}qd+j>Uc-nByZKz~p0RNxpwZ9DaJmr}3#Zn?ijSPx z!{qa~ZQY@t6D)6NSU$CWQB*U!Skh zaJhB$jGKIwY=v@r1sdpbd;j@UzCP*9$O_*HN8tAzzm47I22r|Y?F{*pdI4RxU|d~A zuVp*71XuXuJn}s+1G5KfvTohE@A=7sI9!ovXr&@|zae(q%5jAA5fA;b(xql@Vk~d( zhCeUWbBz_L9~w(Ny_viC8g<0@>#eRcK74(uur-2bpnAJo)>R5C`^)tQj{;kJ?x{>ow9%1@ga;qf!ole;W8zb|pWq)Z~eG1dblt0a9v zV;IxqOk0@s^wJSO4#7}j;K|k;2d*sTKJ6>-e6|*6$JxkU+=Na!>6gkFm{N^|q`T@n z?lD-Inwsx^reYe=Jz;g6X|Hm;+cSoXi`VoyI)f4V@F?I?XlOB=?iwsRLi1#w7~C~L zvc9;M9uJiPV`GEIb1nz&Z~0g&6==pH-(#8v)5uJZ_J?+S-q<8)ju@7j=CWz}-gx)7 zQTq1L5j_h{Q?r8Es+KGHruFd~Q24r}c(oNH= zWXaocNUD;Q>|oZ(^hD8olZy*w3#_8;FR7=O@#xMa4h|HhowjI)+!jw>XjLkCN6?@U zh&NGr-jh2Nofm0FVYqvpbayH0N;h}xU{5?zi0B@!T&km$R&V`3f zVL*O{@7L?FdydF>LXTYbcO`XVzs*R&^fAygU;ZM4{XmP%OQcJF#mPzyXU34UQHNbh z;sJS{ddEn~rp^R| z4Z%+;ykz8>Sd_4Y2TF0yW z>nkI=M-83W?c;Ub!~)IhBOL)BUl-lp*e)?6#m1~3%ca|746R&#-DV7>g%h*hK5{Bb zp*_rajX-ctD%^8_hq%r{7Fxchd=>`7)z;PSF(Q+yHvjWI-7QNs<{XfUnooQ6buBMp z2N=kT2R`~n@I~TC1&^vVah_F;)e5C>SKqQ}X{i#r9G9vrz4Y}cDm!DT$m}IHjQY&V zrp1IPlyF;|d$sNGDH6kj#kyq{&zwpyn2^c16{(f|ai-#7`0iFo`mx#K0qQ(&$L;~b zS?Dx3VkZ44zzO=NZ@~66LQCQ-lLQuKH7TvP&(40U1mo@mBPx+-A0; z((fZYv{=7;zU0}=`*pUeUUNLdSTj|jyvReiaqpGc1{b=aM_Zc&Mwk~7r`VV$LrVGE zaSTYx+uW*?Sz~R779$VlT>FdDe;kHawIX6p)i8~BvdZ0i^x^|)9v^S+U_U?{vkOoy zJ>zeYSI^?$mgEk%tGWrc-%>8xbR0X2TR5@1t(b^NzIK=U<7~N8#W*vO|1C%kwY1+A zB6ph$Z7(u=)FePdFO!ID4w?J-Cn~*SCG&W4GJjJSA8}xT5@Cf>d%4^KoL-i&O&jYX zSp!U3Nv1=*R2{RleO$Z-R(9o@?fHcE@1*vI!|EajtDiQhl*?M{c%>Zq2nfmr8(cm|A>xmWmes@J0t?DjYq^%KfaW3$Vj z3Ed6upDhHLwGE z{oN;|`X`#Y0iyxY=32VuJ%b%{hzQIEJfV#Tl~>62d}@b@P$BJI>e`4Ehkaz-0B7n!O}pD zF$!>AsCY&J4Hy{0FR>9@=R&-F=g>HYBX;%<;emg6j*q|%IO3e=()OQkx&Pb5cxob% zv|MbQ`Ojk9EhME#UMzkTz1c2~iEl}6?hhoX?da##TDv{kT@!ZB1ia7(I&gIE%fjWX z)>TZZ?T0789Yn8_RWQ91@i^Ni3Goc!_3QdpZ^-}O<TCVz*!h*KP`^@##d~!VsPrTg+BV>EhE36 zHBV9mUUDqO8voc=IQi44F_UT%kMgFy@c*xI88G-UAVM$D9)Gj9e+|A0J*OVuE}VEf zcI}eO_S^y>nZKM{(w0;h*Anw+e?4S=o^-s*p3V#B3V)dpYs!=yw~t8B+E&l1dW4R| zUdpwos6K#i&>VdKM7N<%$zjhqO4=SQu%%KwBdWlkeg}F=!fU%oc(PyTuJ=E(SXjQK z81te3=0U~0S@izk(G&)Po|m6`J1YNunXboQ&>h>mdVFqn;1@tLLePk245ttL`z$>V zM3b+`%)zFLpv??_OQ4Ns$*x}FF03w3u_M<+r=U;3%NEWQBA}-aa2MiyA$85*&r7h^ zqu}0**0VC-*xqs^0GjvDTqYWj@K4OltF6&hr56w%iD$zi$U|a+@F?H^F_9KkFiB?u zbDXWIzLRce0Xf3_i1M{h@Y+P~5&%-p@#ZxM;#ypt(h4TR`8@IuKXp@!d^k}N0y-NV z+Nz>1MhJy$E6(Z7%09FR{2RHs(PpesI!zNaD=*?q3xT>jQOLiDt@gbZtFfVS*MQn9 z)@U+UoSvY@fvIO;_V;sZftvNbuN;oC7Y9r8oHIK@yf@+CGo!cE#gkdOiyJLeMZ6X< zhGjd?G@p9x{1yezp|=a8hjvf8x`t}=aB>^YjBSgD$lE8`5;nGq6GAdkpf~n7rpT7X9AdkcP-2n2#q)MY?W)|CAL;0Z4@ zzfXaO_z2@ZyMLEk=&tXAkuLQ_71htAN4$E;uLazyqwP)QE+$_qGNzh%7jn)Isc{oJ zWpdc#pDA5ku|@gxQiv1Bp$S*af5#wZ#GmZOxaCWnUz+5EZzbz*l+v ziWh!f0l0IwGskQbP}RM@V7TbW?;GCA`=)_7cg{yFaG>1qjscl}w>)Nd3%H<#*IZUH zWPTK*k%;r$G)xqwuEB~3j zHUDDoa*f^YqZfjZlQ4kkp1JN`T6xcyYwUWG!2p@S@7p(kp1q@`SwX9<4XbA7LrNhv zbj-ZR70XSD_YJ>#h2rfj(6K@`-FWJ3f9qB6EQpxg?o!o0TORp8vQji-*lTm%odE{@ zC`hL^;>9jNh}zp&FfrCUU^j(GH(b?4cB&%@sE3~d+Uv29Cl`*NL@tkB-Ok@F(V!$F zC=BpcpC26ECM>#zF;rR#%$5MBf>xI7HU^U~(Klz=pZUPkUszcft-_6qx5K8$bBQ-a zx2vOdR(rGxu#~d}3QBV=Egh*?V1el#)At-*^XJc-E#&kXNkA8MKCApVSZAMsokt)L z2lX#Mq&vTSBu>1fq$Ws0NJ)u}XzP3`j+2_Lv+Z?Gx%<$?>hy7ch0x=91ASVtiR$3Q z)8#9+`!#_9aY3fqi5rItbZ;GXMQP$NNnAZYe8gwJ(P`L!mRwL_QtG#O1zr!jdPc$9 z1f@No!nNxpPH;r-@pP~!;UiFf#k6B5B2B2)(~c5hceSR?Ksh<~=j9;={$C zF=^uQ;JitM0FnHC@NHm)JUv(F=33q>8wL!l2Z*lrs`c8JRwTHMsX$kz=cO)wfNyj{ zz?!C8=O*~tD5;{Wmx@?|@5!@g?;p>plCK$!%PQlgve^_(lkrciXzOOc7x`dfsdmw? zI{9l4x8AroA3S~&uTN-u8S2^w{H<&IA3uAsMn*2*f80UyCcZCsJam}8aaiT1thH?M zjcW(<9X&+T%P(JPdoEsr0%!GYVNDFt=YeoNT|DYq8oOY4N5{P()u$0UdK4(&;?z!Y zwq~rCvzQ6yvr}F#O0;ya%XNL!`RShhVLzc$s=O!$u>=Vr@$D;@WH^U%oyp|oZ_XoI z=^+ySp9(v-n;=FlGJ5mOZTGcf^r#6(Wq;DqPZG8JI0G)H%d)QZQn}T~oq!dFb zy<;a{%2_sHkmPonMe4MHh z?UCE?1cr7&)t>f0)*xU&nWh$2=M|Qfxi5%VzqDY37=0Mq;q0QfZ&%YY5+=?ESpyJX zqXsf`mug2EZqoV9qaN|drmYJB&~^yCBYwlmTZUp_ZwQ3%{@Q(+KiJ{u0mu@6>TY^O z2|_v)cn|C7RTFTLaTeNr-C47^yWQT5Qu-EZ>$VkhUQKWHSh2cnMVsfMga@s3;#fnU~7%CJYCDE9QMN zJBRcV5~}9W*-A385|I|uT!<0{y4z7Hp%BNe>A{1Z=YTU?YdY#3Kb$Ltm2-i+B~$(%@Kid z;%!-_nCPLF^$RjH&pCc0SZyz01=Sz{U?9XY)mlOe38;6;8Xt)=z>SLL( zkJ}1tOWa;x&uZh~##nS%iCeKYzcbEi*xEMO0k-qL&<2(Xrk0wR#2|6KQrZ-OjNNkE zy$@>0h-M2jZ zy;QQU+i&G$k2126C}>3cQEMyJp)mb?PvYI2Cdq zAOSCJ8TfR_Ib1|Dq`Pch$j?m+vzFhPU9`YpVu+bzX&DwD`40YV1Yd?Guk25w>kKoZ zxAHi9eFTM~yUo+i?ra;R94~Ilt>O7~kg9il#1H-%#B-d*n+A>e${9DlZPHZ)0x|#a zp5)VAzhDBGVW~zu=`678a1Tm5Y~Zh@aQy>TGI_AKpywzPB(X=I=gx zKeIxi*GmL2NmCvK#vg*Wfbn^N$Gk17r9jv Ac>n+a literal 0 HcmV?d00001 diff --git a/docs/gallery/png/DroneGeneralView.png b/docs/gallery/png/DroneGeneralView.png new file mode 100644 index 0000000000000000000000000000000000000000..28d7b4587ffa601df61d25f208db7e388171572f GIT binary patch literal 56621 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 AKZ|WCJH5wK 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 T@+IP~STGkZozFRY<<|cLlFjn) literal 0 HcmV?d00001 diff --git a/docs/gallery/png/ElevatorStateTransitionView.png b/docs/gallery/png/ElevatorStateTransitionView.png new file mode 100644 index 0000000000000000000000000000000000000000..1a0aa786937c18240371367c1d99c86769283f90 GIT binary patch literal 23005 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/png/OAuthSequenceView.png b/docs/gallery/png/OAuthSequenceView.png new file mode 100644 index 0000000000000000000000000000000000000000..496f50f46e9107270f6542a549c4c2bc46709df1 GIT binary patch literal 15062 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 tkN+=WkK{|`~G;_Uzc literal 0 HcmV?d00001 diff --git a/docs/gallery/png/PipelineActionFlowView.png b/docs/gallery/png/PipelineActionFlowView.png new file mode 100644 index 0000000000000000000000000000000000000000..08dbb69e05e24fe4201aca4a3bfe929323abd8ae GIT binary patch literal 38160 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/png/WorkstationInterconnectionView.png b/docs/gallery/png/WorkstationInterconnectionView.png new file mode 100644 index 0000000000000000000000000000000000000000..8e39bf0b379aba0122d6b4e2caa9dfba7f9b211b GIT binary patch literal 25126 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 + + + + + + + + + + + + + + + + + + + + + + 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..0337689a --- /dev/null +++ b/docs/gallery/svg/DroneGeneralView.svg @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + «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..50758d4a --- /dev/null +++ b/docs/gallery/svg/ElevatorStateTransitionView.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + «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..3b7d2407 --- /dev/null +++ b/docs/gallery/svg/OAuthSequenceView.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + 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..7832ec28 --- /dev/null +++ b/docs/gallery/svg/PipelineActionFlowView.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + «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..abd91d5e --- /dev/null +++ b/docs/gallery/svg/TaxonomyMatrixView.svg @@ -0,0 +1,472 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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..12053f10 --- /dev/null +++ b/docs/gallery/svg/WorkstationInterconnectionView.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + «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/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs index c490c233..8a057a35 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs @@ -58,6 +58,11 @@ private enum Dir /// 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. @@ -66,31 +71,62 @@ public static IReadOnlyList Route( Point2D source, Point2D target, IReadOnlyList obstacles, - double clearance) + double clearance, + PortSide? sourceSide = null, + PortSide? targetSide = null) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(obstacles); - // Build the candidate grid coordinates from endpoints and clearance-offset obstacle edges. - var xs = BuildAxis(source.X, target.X, obstacles, clearance, horizontal: true); - var ys = BuildAxis(source.Y, target.Y, obstacles, clearance, horizontal: false); + // Optionally 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. + var stub = clearance + 8.0; + var routeSource = StepOff(source, sourceSide, stub); + var routeTarget = StepOff(target, targetSide, stub); + + // Build the candidate grid coordinates from the (stubbed) endpoints and obstacle edges. + var xs = BuildAxis(routeSource.X, routeTarget.X, obstacles, clearance, horizontal: true); + var ys = BuildAxis(routeSource.Y, routeTarget.Y, obstacles, clearance, horizontal: false); - var startI = IndexOf(xs, source.X); - var startJ = IndexOf(ys, source.Y); - var goalI = IndexOf(xs, target.X); - var goalJ = IndexOf(ys, target.Y); + var startI = IndexOf(xs, routeSource.X); + var startJ = IndexOf(ys, routeSource.Y); + var goalI = IndexOf(xs, routeTarget.X); + var goalJ = IndexOf(ys, routeTarget.Y); - var path = AStar(xs, ys, startI, startJ, goalI, goalJ, obstacles); - if (path is null) + var path = AStar(xs, ys, startI, startJ, goalI, goalJ, obstacles) + ?? BuildFallback(routeSource, routeTarget); + + // Re-attach the original anchor points outside the stubs. + var full = new List(); + if (sourceSide is not null) { - // No obstacle-free route found: fall back to an L-shaped path. - return BuildFallback(source, target); + full.Add(source); } - return Simplify(path); + 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, + }; + /// /// 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. @@ -323,16 +359,31 @@ private static IReadOnlyList Simplify(List points) var cur = points[k]; var next = points[k + 1]; - // Drop the middle point when prev, cur, next are collinear (all share X or all share Y). - var collinearX = Math.Abs(prev.X - cur.X) < 1e-9 && Math.Abs(cur.X - next.X) < 1e-9; - var collinearY = Math.Abs(prev.Y - cur.Y) < 1e-9 && Math.Abs(cur.Y - next.Y) < 1e-9; + // 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); } } - result.Add(points[^1]); + // 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; } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs index e8058820..30d3628d 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs @@ -223,7 +223,9 @@ private static void AddSuccessionEdges( } } - var waypoints = ChannelRouter.Route(source, target, obstacles, FlowClearance); + var waypoints = ChannelRouter.Route( + source, target, obstacles, FlowClearance, + sourceSide: PortSide.Bottom, targetSide: PortSide.Top); nodes.Add(new LayoutLine( Waypoints: waypoints, SourceArrowhead: ArrowheadStyle.None, diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs index 1bf8af6c..b14d1f59 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs @@ -113,7 +113,7 @@ private static void EmitNode( var boxWidth = (label.Length * theme.FontSizeBody * CharWidthFactor) + (4.0 * theme.LabelPadding); var centreY = y + (rowHeight / 2.0); - // Connector line from the parent's vertical stem to this row. + // 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( @@ -138,11 +138,13 @@ private static void EmitNode( maxRight = Math.Max(maxRight, x + boxWidth); cursorY += rowHeight + (theme.LabelPadding / 2.0); - // Children hang from a vertical stem dropping from this row's left edge. + // 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, centreY, stemX); + EmitNode(child, depth + 1, theme, rowHeight, nodes, ref cursorY, ref maxRight, stemTopY, stemX); } } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index 52007ab2..83c01bd2 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -246,8 +246,11 @@ private static (List Nodes, List Placed, double Width, do int depthLimit) { var margin = 2.0 * theme.LabelPadding; - var hGap = 3.0 * theme.LabelPadding; - var vGap = 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. @@ -453,8 +456,8 @@ private static LayoutLine RouteEdge(PlacedBox from, PlacedBox to, IReadOnlyList< 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 = AnchorToward(from, toCenter); - var target = AnchorToward(to, fromCenter); + 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 @@ -462,7 +465,7 @@ private static LayoutLine RouteEdge(PlacedBox from, PlacedBox to, IReadOnlyList< .Select(b => new Rect(b.X, b.Y, b.Width, b.Height)) .ToList(); - var waypoints = ChannelRouter.Route(source, target, obstacles, EdgeClearance); + var waypoints = ChannelRouter.Route(source, target, obstacles, EdgeClearance, sourceSide, targetSide); // Generalization: open arrowhead points at the supertype (target) end. return new LayoutLine( @@ -473,8 +476,11 @@ private static LayoutLine RouteEdge(PlacedBox from, PlacedBox to, IReadOnlyList< MidpointLabel: null); } - /// Returns the midpoint of the box side whose outward normal best points at the target. - private static Point2D AnchorToward(PlacedBox box, Point2D target) + /// + /// Returns the midpoint of the box side whose outward normal best points at the target, along + /// with that side. + /// + private static (Point2D Point, PortSide Side) AnchorToward(PlacedBox box, Point2D target) { var cx = box.X + (box.Width / 2.0); var cy = box.Y + (box.Height / 2.0); @@ -485,14 +491,14 @@ private static Point2D AnchorToward(PlacedBox box, Point2D target) { // Left or right side. return dx >= 0 - ? new Point2D(box.X + box.Width, cy) - : new Point2D(box.X, cy); + ? (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) - : new Point2D(cx, box.Y); + ? (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. diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs index f61abe2f..102cb4dd 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs @@ -298,7 +298,9 @@ private static void AddPortsAndConnectors( new Point2D(portA.CentreX, portA.CentreY), new Point2D(portB.CentreX, portB.CentreY), obstacles, - ConnectorClearance); + ConnectorClearance, + sourceSide: portA.Side, + targetSide: portB.Side); nodes.Add(new LayoutLine( Waypoints: waypoints, diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs index f048958a..364c87c5 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs @@ -254,8 +254,8 @@ private static void AddTransitions( var from = stateRects[transition.Source]; var to = stateRects[transition.Target]; - var source = AnchorToward(from, Centre(to)); - var target = AnchorToward(to, Centre(from)); + var (source, sourceSide) = AnchorToward(from, Centre(to)); + var (target, targetSide) = AnchorToward(to, Centre(from)); var obstacles = new List(); for (var i = 0; i < stateRects.Length; i++) @@ -266,7 +266,7 @@ private static void AddTransitions( } } - var waypoints = ChannelRouter.Route(source, target, obstacles, TransitionClearance); + var waypoints = ChannelRouter.Route(source, target, obstacles, TransitionClearance, sourceSide, targetSide); nodes.Add(new LayoutLine( Waypoints: waypoints, SourceArrowhead: ArrowheadStyle.None, @@ -300,8 +300,11 @@ private static LayoutLine BuildSelfLoop(Rect box, string? label) MidpointLabel: label); } - /// Returns the midpoint of the box side whose outward normal best points at the target. - private static Point2D AnchorToward(Rect box, Point2D target) + /// + /// Returns the midpoint of the box side whose outward normal best points at the target, along + /// with that side. + /// + private static (Point2D Point, PortSide Side) AnchorToward(Rect box, Point2D target) { var cx = box.X + (box.Width / 2.0); var cy = box.Y + (box.Height / 2.0); @@ -310,10 +313,14 @@ private static Point2D AnchorToward(Rect box, Point2D target) if (Math.Abs(dx) >= Math.Abs(dy)) { - return dx >= 0 ? new Point2D(box.X + box.Width, cy) : new Point2D(box.X, cy); + return dx >= 0 + ? (new Point2D(box.X + box.Width, cy), PortSide.Right) + : (new Point2D(box.X, cy), PortSide.Left); } - return dy >= 0 ? new Point2D(cx, box.Y + box.Height) : new Point2D(cx, box.Y); + return dy >= 0 + ? (new Point2D(cx, box.Y + box.Height), PortSide.Bottom) + : (new Point2D(cx, box.Y), PortSide.Top); } /// Returns the centre point of a rectangle. diff --git a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs index b7e593e4..4195e0fa 100644 --- a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs @@ -373,17 +373,42 @@ private static void RenderBoxTitle(StringBuilder sb, LayoutBox box, Theme theme, cursorY += theme.FontSizeBody + theme.LabelPadding; } - // Bold name label + // Bold name label — only constrain width when the text would actually overflow the box, + // so short labels render at their natural size instead of being stretched to fill. if (box.Label != null) { var textY = (cursorY + theme.FontSizeTitle / 2.0) * scale; - var availableWidth = (box.Width - 2 * theme.LabelPadding) * scale; + var availableWidth = box.Width - (2 * theme.LabelPadding); + var fit = FitTextLength(box.Label, theme.FontSizeTitle, availableWidth, scale); sb.Append(CultureInfo.InvariantCulture, - $""" {EscapeXml(box.Label)}"""); + $""" {EscapeXml(box.Label)}"""); sb.AppendLine(); } } + /// + /// Returns an SVG textLength/lengthAdjust attribute fragment that constrains text + /// to only when the text's estimated natural width exceeds it; + /// otherwise returns an empty string so the text renders at its natural width (no stretching). + /// + /// The text to be rendered. + /// Unscaled font size of the text. + /// Unscaled width available for the text. + /// Uniform scale factor. + /// A leading-space attribute fragment, or an empty string when no constraint is needed. + private static string FitTextLength(string text, double fontSize, double availableWidth, double scale) + { + // Rough average glyph-width estimate; matches the layout engine's sizing factor. + const double GlyphWidthFactor = 0.6; + var estimatedWidth = text.Length * fontSize * GlyphWidthFactor; + if (availableWidth <= 0 || estimatedWidth <= availableWidth) + { + return string.Empty; + } + + return $""" textLength="{F(availableWidth * scale)}" lengthAdjust="spacingAndGlyphs" """.TrimEnd(); + } + /// /// Renders the compartments of a below the title area as SVG /// <line> dividers and <text> elements. @@ -493,10 +518,20 @@ private static void RenderLine(StringBuilder sb, LayoutLine line, Theme theme, d $""" """); sb.AppendLine(); - // Draw the optional midpoint label as a centered text element + // Draw the optional midpoint label as a centered text element on a white background so the + // line does not strike through the text (mirrors the PNG renderer). if (line.MidpointLabel != null) { var (midX, midY) = ComputeLineMidpoint(line.Waypoints); + var fontSize = theme.FontSizeBody; + var estWidth = (line.MidpointLabel.Length * fontSize * 0.6) + theme.LabelPadding; + var bgX = (midX * scale) - (estWidth * scale / 2.0); + var bgY = (midY - (fontSize / 2.0) - 1.0) * scale; + var bgW = estWidth * scale; + var bgH = (fontSize + 2.0) * scale; + sb.Append(CultureInfo.InvariantCulture, + $""" """); + sb.AppendLine(); sb.Append(CultureInfo.InvariantCulture, $""" {EscapeXml(line.MidpointLabel)}"""); sb.AppendLine(); @@ -645,12 +680,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/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs index b3c96eb8..b3114611 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs @@ -85,6 +85,51 @@ public void Route_AlignedEndpoints_ProducesStraightLine() 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); + } + /// /// Asserts that the path begins at the expected source and ends at the expected target. /// From 81f25bf2e103ded904d1923ad85fee08089a8452 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 19:51:33 -0400 Subject: [PATCH 10/14] fix: perpendicular connector stubs, robust clearance-retry routing, layout-failure warnings, two-pass label z-order, longest-segment label placement - ChannelRouter: RouteWithStatus with clearance-retry (full->half->quarter->0) and perpendicular edge stubs; U-turn-safe Simplify; obstacle-aware fallback - Add diagnostic warning channel (LayoutTree.Warnings -> RenderOutput.Warnings -> RenderCommand prints) with LayoutWarnings.ForCrossings - Strategies count connector crossings and emit warnings; thread anchor sides - SVG: conditional textLength (FitTextLength) fixes font stretching; label-bg filter for auto-sized text background - Two-pass rendering: draw wires/boxes first, connector labels last (z-order); longest-segment label placement keeps labels off boxes - Browser tree stems drop from parent bottom-left - Regenerate gallery (zero layout warnings) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../png/ElevatorStateTransitionView.png | Bin 23005 -> 28300 bytes docs/gallery/svg/AvionicsBrowserView.svg | 4 + docs/gallery/svg/DroneGeneralView.svg | 4 + .../svg/ElevatorStateTransitionView.svg | 86 +++++------ docs/gallery/svg/OAuthSequenceView.svg | 31 ++-- docs/gallery/svg/PipelineActionFlowView.svg | 4 + docs/gallery/svg/TaxonomyMatrixView.svg | 4 + .../svg/WorkstationInterconnectionView.svg | 4 + .../Layout/Engine/ChannelRouter.cs | 144 ++++++++++++++++-- .../Internal/ActionFlowViewLayoutStrategy.cs | 24 ++- .../Internal/GeneralViewLayoutStrategy.cs | 38 +++-- .../InterconnectionViewLayoutStrategy.cs | 21 ++- .../Layout/Internal/LayoutWarnings.cs | 36 +++++ .../StateTransitionViewLayoutStrategy.cs | 26 +++- .../Layout/LayoutTree.cs | 9 +- .../Rendering/DiagramRenderer.cs | 2 +- .../Rendering/RenderOutput.cs | 9 +- .../PngRenderer.cs | 103 +++++++++++-- .../SvgRenderer.cs | 125 +++++++++++---- .../Render/RenderCommand.cs | 6 + .../Layout/Engine/ChannelRouterTests.cs | 52 +++++++ .../Layout/LayoutWarningsTests.cs | 41 +++++ 22 files changed, 619 insertions(+), 154 deletions(-) create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs diff --git a/docs/gallery/png/ElevatorStateTransitionView.png b/docs/gallery/png/ElevatorStateTransitionView.png index 1a0aa786937c18240371367c1d99c86769283f90..561fd6015e74e1737fb403478bf288a0df797cbd 100644 GIT binary patch literal 28300 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 literal 23005 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 diff --git a/docs/gallery/svg/AvionicsBrowserView.svg b/docs/gallery/svg/AvionicsBrowserView.svg index b8f4c49e..b8245e15 100644 --- a/docs/gallery/svg/AvionicsBrowserView.svg +++ b/docs/gallery/svg/AvionicsBrowserView.svg @@ -18,6 +18,10 @@ + + + + package AvionicsSystem diff --git a/docs/gallery/svg/DroneGeneralView.svg b/docs/gallery/svg/DroneGeneralView.svg index 0337689a..1344cc4f 100644 --- a/docs/gallery/svg/DroneGeneralView.svg +++ b/docs/gallery/svg/DroneGeneralView.svg @@ -18,6 +18,10 @@ + + + + «package» diff --git a/docs/gallery/svg/ElevatorStateTransitionView.svg b/docs/gallery/svg/ElevatorStateTransitionView.svg index 50758d4a..2e45fc23 100644 --- a/docs/gallery/svg/ElevatorStateTransitionView.svg +++ b/docs/gallery/svg/ElevatorStateTransitionView.svg @@ -1,4 +1,4 @@ - + @@ -18,49 +18,45 @@ + + + + - - «state» - idle - - «state» - doorsOpening - - «state» - doorsOpen - - «state» - doorsClosing - - «state» - movingUp - - «state» - movingDown - - - - - [callReceived] - - - [doorsClosed] - - - [atFloor] - - - [atFloor] - - - [doorsAreOpen] - - - [timeout] - - - [goingDown] - - - [idleTimeout] + + «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 index 3b7d2407..c05271e7 100644 --- a/docs/gallery/svg/OAuthSequenceView.svg +++ b/docs/gallery/svg/OAuthSequenceView.svg @@ -18,6 +18,10 @@ + + + + user @@ -32,30 +36,21 @@ resourceServer - - openApp - - redirect - - promptCredentials - - submitCredentials - - authCode - - exchangeCode - - accessToken - - fetchResource - - resourceData + openApp + redirect + promptCredentials + submitCredentials + authCode + exchangeCode + accessToken + fetchResource + resourceData diff --git a/docs/gallery/svg/PipelineActionFlowView.svg b/docs/gallery/svg/PipelineActionFlowView.svg index 7832ec28..5c89f0d9 100644 --- a/docs/gallery/svg/PipelineActionFlowView.svg +++ b/docs/gallery/svg/PipelineActionFlowView.svg @@ -18,6 +18,10 @@ + + + + «action» diff --git a/docs/gallery/svg/TaxonomyMatrixView.svg b/docs/gallery/svg/TaxonomyMatrixView.svg index abd91d5e..db4cda71 100644 --- a/docs/gallery/svg/TaxonomyMatrixView.svg +++ b/docs/gallery/svg/TaxonomyMatrixView.svg @@ -18,6 +18,10 @@ + + + + diff --git a/docs/gallery/svg/WorkstationInterconnectionView.svg b/docs/gallery/svg/WorkstationInterconnectionView.svg index 12053f10..c0bcd1f3 100644 --- a/docs/gallery/svg/WorkstationInterconnectionView.svg +++ b/docs/gallery/svg/WorkstationInterconnectionView.svg @@ -18,6 +18,10 @@ + + + + «part def» diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs index 8a057a35..4cf64123 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs @@ -13,6 +13,17 @@ namespace DemaConsulting.SysML2Tools.Layout.Engine; /// 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. @@ -68,6 +79,28 @@ private enum Dir /// . 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, @@ -79,25 +112,62 @@ public static IReadOnlyList Route( ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(obstacles); - // Optionally 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. + // 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. var stub = clearance + 8.0; var routeSource = StepOff(source, sourceSide, stub); var routeTarget = StepOff(target, targetSide, stub); - // Build the candidate grid coordinates from the (stubbed) endpoints and obstacle edges. - var xs = BuildAxis(routeSource.X, routeTarget.X, obstacles, clearance, horizontal: true); - var ys = BuildAxis(routeSource.Y, routeTarget.Y, obstacles, clearance, horizontal: false); + // 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); + + if (path is not null) + { + return new RouteResult(Finalize(source, target, sourceSide, targetSide, path), Crossed: false); + } + } - var startI = IndexOf(xs, routeSource.X); - var startJ = IndexOf(ys, routeSource.Y); - var goalI = IndexOf(xs, routeTarget.X); - var goalJ = IndexOf(ys, routeTarget.Y); + // 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); + } - var path = AStar(xs, ys, startI, startJ, goalI, goalJ, obstacles) - ?? BuildFallback(routeSource, routeTarget); + /// + /// 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-attach the original anchor points outside the stubs. + /// + /// 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) { @@ -388,9 +458,14 @@ private static IReadOnlyList Simplify(List points) } /// - /// Builds a simple L-shaped fallback route used when A* cannot find an obstacle-free path. + /// 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 BuildFallback(Point2D source, Point2D target) + 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) @@ -398,7 +473,44 @@ private static IReadOnlyList BuildFallback(Point2D source, Point2D targ return [source, target]; } - // Otherwise route horizontally then vertically through the elbow point. - return [source, new Point2D(target.X, source.Y), 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/Internal/ActionFlowViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs index 30d3628d..013bfb64 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs @@ -79,12 +79,13 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) nodes.Add(MakeActionBox(actions[i], rects[i])); } - AddSuccessionEdges(edges, rects, nodes); + var crossings = AddSuccessionEdges(edges, rects, nodes); AddStartAndDone(actions, rects, edges, layered, nodes); var width = layered.Width; var height = layered.Height + (2.0 * MarkerBand); - return new LayoutTree(width, height, nodes); + 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. @@ -203,12 +204,16 @@ private static LayoutBox MakeActionBox(ActionItem action, Rect rect) => Children: [], Keyword: "action"); - /// Adds the succession flow edges (top-to-bottom) between action boxes. - private static void AddSuccessionEdges( + /// + /// 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); @@ -223,16 +228,23 @@ private static void AddSuccessionEdges( } } - var waypoints = ChannelRouter.Route( + var route = ChannelRouter.RouteWithStatus( source, target, obstacles, FlowClearance, sourceSide: PortSide.Bottom, targetSide: PortSide.Top); + if (route.Crossed) + { + crossings++; + } + nodes.Add(new LayoutLine( - Waypoints: waypoints, + Waypoints: route.Waypoints, SourceArrowhead: ArrowheadStyle.None, TargetArrowhead: ArrowheadStyle.Filled, LineStyle: LineStyle.Solid, MidpointLabel: null)); } + + return crossings; } /// diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index 83c01bd2..07592b8e 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -68,10 +68,11 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var (nodes, placed, canvasWidth, canvasHeight) = PlaceGroups(groups, theme, options.DepthLimit); // Route specialization edges between placed boxes. - var edges = BuildSpecializationEdges(defs, placed); + var (edges, crossings) = BuildSpecializationEdges(defs, placed); nodes.AddRange(edges); - return new LayoutTree(canvasWidth, canvasHeight, nodes); + var warnings = LayoutWarnings.ForCrossings(context.ViewName, crossings); + return new LayoutTree(canvasWidth, canvasHeight, nodes) { Warnings = warnings }; } /// @@ -388,10 +389,16 @@ private static LayoutBox MakeDefBox(DefBox def, double x, double y, int depth) = Children: [], Keyword: def.Keyword); - /// Builds specialization (generalization) edges between placed definition boxes. - private static List BuildSpecializationEdges(IReadOnlyList defs, IReadOnlyList placed) + /// + /// Builds specialization (generalization) edges between placed definition boxes, returning the + /// edges and the number that could not be routed without crossing a box. + /// + private static (List Edges, int Crossings) BuildSpecializationEdges( + IReadOnlyList defs, + IReadOnlyList placed) { 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); @@ -417,11 +424,16 @@ private static List BuildSpecializationEdges(IReadOnlyList d continue; } - edges.Add(RouteEdge(fromBox, target, placed)); + var (edge, crossed) = RouteEdge(fromBox, target, placed); + edges.Add(edge); + if (crossed) + { + crossings++; + } } } - return edges; + return (edges, crossings); } /// Resolves a supertype reference to a placed box by qualified then simple name. @@ -450,8 +462,11 @@ private static bool TryResolve( return false; } - /// Routes a single specialization edge from a subtype box to its supertype box. - private static LayoutLine RouteEdge(PlacedBox from, PlacedBox to, IReadOnlyList placed) + /// + /// Routes a single specialization edge from a subtype box to its supertype box, returning the + /// edge and whether it had to cross another box. + /// + private static (LayoutLine Edge, bool Crossed) RouteEdge(PlacedBox from, PlacedBox to, IReadOnlyList placed) { 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)); @@ -465,15 +480,16 @@ private static LayoutLine RouteEdge(PlacedBox from, PlacedBox to, IReadOnlyList< .Select(b => new Rect(b.X, b.Y, b.Width, b.Height)) .ToList(); - var waypoints = ChannelRouter.Route(source, target, obstacles, EdgeClearance, sourceSide, targetSide); + var route = ChannelRouter.RouteWithStatus(source, target, obstacles, EdgeClearance, sourceSide, targetSide); // Generalization: open arrowhead points at the supertype (target) end. - return new LayoutLine( - Waypoints: waypoints, + var edge = new LayoutLine( + Waypoints: route.Waypoints, SourceArrowhead: ArrowheadStyle.None, TargetArrowhead: ArrowheadStyle.Open, LineStyle: LineStyle.Solid, MidpointLabel: null); + return (edge, route.Crossed); } /// diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs index 102cb4dd..d14714a4 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs @@ -103,9 +103,10 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) } // Ports and connectors. - AddPortsAndConnectors(parts, partRects, pairs, nodes); + var crossings = AddPortsAndConnectors(parts, partRects, pairs, nodes); - return new LayoutTree(containerWidth, containerHeight, nodes); + var warnings = LayoutWarnings.ForCrossings(context.ViewName, crossings); + return new LayoutTree(containerWidth, containerHeight, nodes) { Warnings = warnings }; } /// @@ -237,9 +238,10 @@ private static LayoutBox MakePartBox(PartItem part, Rect rect) /// /// 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. + /// 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 void AddPortsAndConnectors( + private static int AddPortsAndConnectors( IReadOnlyList parts, Rect[] partRects, IReadOnlyList pairs, @@ -276,6 +278,7 @@ private static void AddPortsAndConnectors( } // 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); @@ -294,21 +297,27 @@ private static void AddPortsAndConnectors( } } - var waypoints = ChannelRouter.Route( + 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: waypoints, + Waypoints: route.Waypoints, SourceArrowhead: ArrowheadStyle.None, TargetArrowhead: ArrowheadStyle.None, LineStyle: LineStyle.Solid, MidpointLabel: null)); } + + return crossings; } /// Returns the centre point of a rectangle. 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/StateTransitionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs index 364c87c5..4cf090a9 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs @@ -29,7 +29,7 @@ internal sealed class StateTransitionViewLayoutStrategy : ILayoutStrategy private const double CharWidthFactor = 0.62; /// Nominal spacing between adjacent state centres in the force layout. - private const double StateSpacing = 160.0; + private const double StateSpacing = 240.0; /// Clearance kept between routed transitions and state boxes. private const double TransitionClearance = 12.0; @@ -92,9 +92,10 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) AddInitialMarker(stateRects[0], nodes); // Transition edges with guard labels. - AddTransitions(transitions, stateRects, nodes); + var crossings = AddTransitions(transitions, stateRects, nodes); - return new LayoutTree(force.Width, force.Height, 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. @@ -236,12 +237,16 @@ private static void AddInitialMarker(Rect first, List nodes) MidpointLabel: null)); } - /// Adds transition edges (with guard labels) between state boxes. - private static void AddTransitions( + /// + /// Adds transition edges (with guard labels) between state boxes, returning the number of edges + /// that could not be routed without crossing a state box. + /// + private static int AddTransitions( IReadOnlyList transitions, Rect[] stateRects, List nodes) { + var crossings = 0; foreach (var transition in transitions) { var label = transition.Guard is { Length: > 0 } g ? $"[{g}]" : null; @@ -266,14 +271,21 @@ private static void AddTransitions( } } - var waypoints = ChannelRouter.Route(source, target, obstacles, TransitionClearance, sourceSide, targetSide); + var route = ChannelRouter.RouteWithStatus(source, target, obstacles, TransitionClearance, sourceSide, targetSide); + if (route.Crossed) + { + crossings++; + } + nodes.Add(new LayoutLine( - Waypoints: waypoints, + Waypoints: route.Waypoints, SourceArrowhead: ArrowheadStyle.None, TargetArrowhead: ArrowheadStyle.Filled, LineStyle: LineStyle.Solid, MidpointLabel: label)); } + + return crossings; } /// Builds a small self-transition loop above the state box. 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 19c9f172..ea092775 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs @@ -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; 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.Png/PngRenderer.cs b/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs index 2ef7f2c7..fb90c7e0 100644 --- a/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs @@ -189,6 +189,13 @@ 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. + foreach (var line in CollectLines(layout.Nodes)) + { + RenderLineLabel(canvas, line, options); + } + // Encode as PNG and write to the output stream using var image = SKImage.FromBitmap(bitmap); using var data = image.Encode(SKEncodedImageFormat.Png, 100); @@ -606,10 +613,62 @@ 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. + private static void RenderLineLabel(SKCanvas canvas, LayoutLine line, RenderOptions options) + { + if (line.MidpointLabel is null) + { + return; + } + + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + RenderLineMidpointLabel(canvas, line.Waypoints, 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; + } } } @@ -810,25 +869,39 @@ private static void RenderLineMidpointLabel( } /// - /// 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. + /// Computes the position for an edge's midpoint label: the midpoint of the longest segment of + /// the polyline. Long segments are the open runs between boxes, so the label is far less likely + /// to land on top of a box than the path's geometric midpoint would be. /// /// Ordered waypoints; must contain at least one entry. - /// The (X, Y) coordinates of the midpoint in logical pixels. + /// The (X, Y) coordinates of the label position in logical pixels. private static (double X, double Y) ComputeLineMidpoint(IReadOnlyList waypoints) { - var n = waypoints.Count; - if (n % 2 == 1) + if (waypoints.Count == 1) { - // Odd: middle element is the exact midpoint - return (waypoints[n / 2].X, waypoints[n / 2].Y); + return (waypoints[0].X, waypoints[0].Y); + } + + // Find the longest segment and return its midpoint. + var bestLength = -1.0; + var bestX = waypoints[0].X; + var bestY = waypoints[0].Y; + 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 = (dx * dx) + (dy * dy); + if (length > bestLength) + { + bestLength = length; + bestX = (a.X + b.X) / 2.0; + bestY = (a.Y + b.Y) / 2.0; + } } - // 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); + return (bestX, bestY); } /// diff --git a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs index 4195e0fa..2f9aaf7b 100644 --- a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs @@ -84,12 +84,19 @@ 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. + foreach (var line in CollectLines(layout.Nodes)) + { + RenderLineLabel(sb, line, theme, options.Scale); + } + // Close SVG root sb.AppendLine(""); @@ -168,6 +175,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(" "); } @@ -518,23 +532,64 @@ private static void RenderLine(StringBuilder sb, LayoutLine line, Theme theme, d $""" """); sb.AppendLine(); - // Draw the optional midpoint label as a centered text element on a white background so the - // line does not strike through the text (mirrors the PNG renderer). - 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. + private static void RenderLineLabel(StringBuilder sb, LayoutLine line, Theme theme, double scale) + { + if (line.MidpointLabel is null) { - var (midX, midY) = ComputeLineMidpoint(line.Waypoints); - var fontSize = theme.FontSizeBody; - var estWidth = (line.MidpointLabel.Length * fontSize * 0.6) + theme.LabelPadding; - var bgX = (midX * scale) - (estWidth * scale / 2.0); - var bgY = (midY - (fontSize / 2.0) - 1.0) * scale; - var bgW = estWidth * scale; - var bgH = (fontSize + 2.0) * scale; - sb.Append(CultureInfo.InvariantCulture, - $""" """); - sb.AppendLine(); - sb.Append(CultureInfo.InvariantCulture, - $""" {EscapeXml(line.MidpointLabel)}"""); - sb.AppendLine(); + return; + } + + var (midX, midY) = ComputeLineMidpoint(line.Waypoints); + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(line.MidpointLabel)}"""); + sb.AppendLine(); + } + + /// Recursively collects all nodes from a node tree. + /// Top-level nodes to walk. + /// Every line node, including those nested inside boxes or bands. + private static IEnumerable CollectLines(IReadOnlyList nodes) + { + foreach (var node in nodes) + { + switch (node) + { + case LayoutLine line: + yield return line; + break; + + case LayoutBox box: + foreach (var inner in CollectLines(box.Children)) + { + yield return inner; + } + + break; + + case LayoutBand band: + foreach (var inner in CollectLines(band.Children)) + { + yield return inner; + } + + break; + + default: + break; + } } } @@ -641,23 +696,39 @@ private static string BuildLinePath( } /// - /// 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. + /// Computes the position for an edge's midpoint label: the midpoint of the longest segment of + /// the polyline. Long segments are the open runs between boxes, so the label is far less likely + /// to land on top of a box than the path's geometric midpoint would be. /// /// Ordered waypoints; must contain at least one entry. - /// The (X, Y) coordinates of the midpoint in logical pixels. + /// The (X, Y) coordinates of the label position in logical pixels. private static (double X, double Y) ComputeLineMidpoint(IReadOnlyList waypoints) { - var n = waypoints.Count; - if (n % 2 == 1) + if (waypoints.Count == 1) { - return (waypoints[n / 2].X, waypoints[n / 2].Y); + return (waypoints[0].X, waypoints[0].Y); + } + + // Find the longest segment and return its midpoint. + var bestLength = -1.0; + var bestX = waypoints[0].X; + var bestY = waypoints[0].Y; + 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 = (dx * dx) + (dy * dy); + if (length > bestLength) + { + bestLength = length; + bestX = (a.X + b.X) / 2.0; + bestY = (a.Y + b.Y) / 2.0; + } } - var lo = waypoints[n / 2 - 1]; - var hi = waypoints[n / 2]; - return ((lo.X + hi.X) / 2.0, (lo.Y + hi.Y) / 2.0); + return (bestX, bestY); } /// 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/Engine/ChannelRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs index b3114611..7e06cc0b 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs @@ -130,6 +130,58 @@ public void Route_WithTargetSide_EntersPerpendicular() 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); + } + /// /// Asserts that the path begins at the expected source and ends at the expected target. /// 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); + } +} From 5d40d64774c1349d5c8614e739642346c290c2ba Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 20:13:37 -0400 Subject: [PATCH 11/14] fix: collision-aware connector label placement to avoid label-label overlap - Add shared ConnectorLabelPlacer (Core) used by both SVG and PNG renderers: prefers the longest-segment midpoint, but falls back to a shorter segment or nudges perpendicular when a label would overlap an already-placed label (for example where two connectors cross) - Renderers compute all label positions up front, then draw labels last - Remove duplicated ComputeLineMidpoint from both renderers - Regenerate Elevator gallery images ([timeout]/[atFloor] no longer overlap) - Add ConnectorLabelPlacer tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../png/ElevatorStateTransitionView.png | Bin 28300 -> 28366 bytes .../svg/ElevatorStateTransitionView.svg | 2 +- .../Layout/ConnectorLabelPlacer.cs | 171 ++++++++++++++++++ .../PngRenderer.cs | 62 ++----- .../SvgRenderer.cs | 53 ++---- .../Layout/ConnectorLabelPlacerTests.cs | 77 ++++++++ 6 files changed, 278 insertions(+), 87 deletions(-) create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/ConnectorLabelPlacer.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/ConnectorLabelPlacerTests.cs diff --git a/docs/gallery/png/ElevatorStateTransitionView.png b/docs/gallery/png/ElevatorStateTransitionView.png index 561fd6015e74e1737fb403478bf288a0df797cbd..a63c1215cb5b0ea085098b0e16343696473e0d9d 100644 GIT binary patch literal 28366 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 diff --git a/docs/gallery/svg/ElevatorStateTransitionView.svg b/docs/gallery/svg/ElevatorStateTransitionView.svg index 2e45fc23..3447f68a 100644 --- a/docs/gallery/svg/ElevatorStateTransitionView.svg +++ b/docs/gallery/svg/ElevatorStateTransitionView.svg @@ -56,7 +56,7 @@ [atFloor] [atFloor] [doorsAreOpen] - [timeout] + [timeout] [goingDown] [idleTimeout] 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.Png/PngRenderer.cs b/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs index fb90c7e0..665e77db 100644 --- a/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Png/PngRenderer.cs @@ -190,10 +190,16 @@ public void Render(LayoutTree layout, RenderOptions options, Stream output) } // 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. - foreach (var line in CollectLines(layout.Nodes)) + // 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) { - RenderLineLabel(canvas, line, options); + 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 @@ -624,7 +630,9 @@ private static void RenderLine(SKCanvas canvas, LayoutLine line, RenderOptions o /// Canvas to draw on. /// The line whose label is rendered. /// Render options providing theme and scale. - private static void RenderLineLabel(SKCanvas canvas, LayoutLine line, RenderOptions options) + /// 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) { @@ -634,7 +642,7 @@ private static void RenderLineLabel(SKCanvas canvas, LayoutLine line, RenderOpti var theme = options.Theme; var scale = (float)options.Scale; var strokeColor = SKColor.Parse(theme.StrokeColor); - RenderLineMidpointLabel(canvas, line.Waypoints, line.MidpointLabel, theme, scale, strokeColor); + RenderLineMidpointLabel(canvas, midX, midY, line.MidpointLabel, theme, scale, strokeColor); } /// Recursively collects all nodes from a node tree. @@ -827,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); @@ -868,42 +876,6 @@ private static void RenderLineMidpointLabel( canvas.DrawText(label, scaledX, scaledY, textPaint); } - /// - /// Computes the position for an edge's midpoint label: the midpoint of the longest segment of - /// the polyline. Long segments are the open runs between boxes, so the label is far less likely - /// to land on top of a box than the path's geometric midpoint would be. - /// - /// Ordered waypoints; must contain at least one entry. - /// The (X, Y) coordinates of the label position in logical pixels. - private static (double X, double Y) ComputeLineMidpoint(IReadOnlyList waypoints) - { - if (waypoints.Count == 1) - { - return (waypoints[0].X, waypoints[0].Y); - } - - // Find the longest segment and return its midpoint. - var bestLength = -1.0; - var bestX = waypoints[0].X; - var bestY = waypoints[0].Y; - 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 = (dx * dx) + (dy * dy); - if (length > bestLength) - { - bestLength = length; - bestX = (a.X + b.X) / 2.0; - bestY = (a.Y + b.Y) / 2.0; - } - } - - return (bestX, bestY); - } - /// /// 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 2f9aaf7b..d8a8b936 100644 --- a/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Svg/SvgRenderer.cs @@ -91,10 +91,16 @@ public void Render(LayoutTree layout, RenderOptions options, Stream output) } // 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. - foreach (var line in CollectLines(layout.Nodes)) + // 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) { - RenderLineLabel(sb, line, theme, options.Scale); + 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 @@ -545,14 +551,15 @@ private static void RenderLine(StringBuilder sb, LayoutLine line, Theme theme, d /// The line whose label is rendered. /// Visual theme providing font and color settings. /// Uniform scale factor. - private static void RenderLineLabel(StringBuilder sb, LayoutLine line, Theme theme, double scale) + /// 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; } - var (midX, midY) = ComputeLineMidpoint(line.Waypoints); sb.Append(CultureInfo.InvariantCulture, $""" {EscapeXml(line.MidpointLabel)}"""); sb.AppendLine(); @@ -695,42 +702,6 @@ private static string BuildLinePath( return sb.ToString(); } - /// - /// Computes the position for an edge's midpoint label: the midpoint of the longest segment of - /// the polyline. Long segments are the open runs between boxes, so the label is far less likely - /// to land on top of a box than the path's geometric midpoint would be. - /// - /// Ordered waypoints; must contain at least one entry. - /// The (X, Y) coordinates of the label position in logical pixels. - private static (double X, double Y) ComputeLineMidpoint(IReadOnlyList waypoints) - { - if (waypoints.Count == 1) - { - return (waypoints[0].X, waypoints[0].Y); - } - - // Find the longest segment and return its midpoint. - var bestLength = -1.0; - var bestX = waypoints[0].X; - var bestY = waypoints[0].Y; - 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 = (dx * dx) + (dy * dy); - if (length > bestLength) - { - bestLength = length; - bestX = (a.X + b.X) / 2.0; - bestY = (a.Y + b.Y) / 2.0; - } - } - - return (bestX, bestY); - } - /// + /// + /// 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; - foreach (var transition in transitions) + 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) @@ -257,21 +335,16 @@ private static int AddTransitions( continue; } - var from = stateRects[transition.Source]; - var to = stateRects[transition.Target]; - var (source, sourceSide) = AnchorToward(from, Centre(to)); - var (target, targetSide) = AnchorToward(to, Centre(from)); - var obstacles = new List(); - for (var i = 0; i < stateRects.Length; i++) + for (var j = 0; j < stateRects.Length; j++) { - if (i != transition.Source && i != transition.Target) + if (j != transition.Source && j != transition.Target) { - obstacles.Add(stateRects[i]); + obstacles.Add(stateRects[j]); } } - var route = ChannelRouter.RouteWithStatus(source, target, obstacles, TransitionClearance, sourceSide, targetSide); + var route = ChannelRouter.RouteWithStatus(srcPoint[i], tgtPoint[i], obstacles, TransitionClearance, srcSide[i], tgtSide[i]); if (route.Crossed) { crossings++; @@ -288,6 +361,25 @@ private static int AddTransitions( 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) { @@ -313,10 +405,9 @@ private static LayoutLine BuildSelfLoop(Rect box, string? label) } /// - /// Returns the midpoint of the box side whose outward normal best points at the target, along - /// with that side. + /// Returns the side of the box whose outward normal best points at the target. /// - private static (Point2D Point, PortSide Side) AnchorToward(Rect box, Point2D 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); @@ -325,16 +416,31 @@ private static (Point2D Point, PortSide Side) AnchorToward(Rect box, Point2D tar if (Math.Abs(dx) >= Math.Abs(dy)) { - return dx >= 0 - ? (new Point2D(box.X + box.Width, cy), PortSide.Right) - : (new Point2D(box.X, cy), PortSide.Left); + return dx >= 0 ? PortSide.Right : PortSide.Left; } - return dy >= 0 - ? (new Point2D(cx, box.Y + box.Height), PortSide.Bottom) - : (new Point2D(cx, box.Y), PortSide.Top); + 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)); diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs index 51566e5b..2acf576f 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs @@ -96,4 +96,49 @@ public void StateTransitionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas( 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."); + } } From 526f97620a3f97ee80b22f39071488406f30f58e Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 21:23:29 -0400 Subject: [PATCH 13/14] fix: straighten flow/interconnection layouts and enforce connector clearance - LayeredLayoutEngine: add x-coordinate alignment (centre layers + neighbor- averaging relaxation) so Action Flow forms a straight spine instead of left-aligning every layer - ActionFlowViewLayoutStrategy: centre start/done markers over the actions they connect to keep entry/exit arrows vertical - ChannelRouter: enforce clearance in SegmentBlocked by inflating obstacles, so routes no longer graze box edges (clearance 0 retry preserves prior behaviour) - InterconnectionViewLayoutStrategy: snap the two ports of a connection to a shared axis (within the boxes' overlap) when each is alone on its edge, so single-port connectors render as straight lines without moving boxes - Regenerate affected gallery images - Add connector-clearance ChannelRouter test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/gallery/png/DroneGeneralView.png | Bin 56621 -> 56704 bytes docs/gallery/png/PipelineActionFlowView.png | Bin 38160 -> 37296 bytes .../png/WorkstationInterconnectionView.png | Bin 25126 -> 24557 bytes docs/gallery/svg/DroneGeneralView.svg | 4 +- docs/gallery/svg/PipelineActionFlowView.svg | 112 +++++++-------- .../svg/WorkstationInterconnectionView.svg | 16 +-- .../Layout/Engine/ChannelRouter.cs | 55 ++++++-- .../Layout/Engine/LayeredLayoutEngine.cs | 127 ++++++++++++++++-- .../Internal/ActionFlowViewLayoutStrategy.cs | 36 ++--- .../InterconnectionViewLayoutStrategy.cs | 81 ++++++++++- .../Layout/Engine/ChannelRouterTests.cs | 41 ++++++ 11 files changed, 363 insertions(+), 109 deletions(-) diff --git a/docs/gallery/png/DroneGeneralView.png b/docs/gallery/png/DroneGeneralView.png index 28d7b4587ffa601df61d25f208db7e388171572f..867d859d0a6e59f2378c21fec41792b2bb2116cb 100644 GIT binary patch delta 37900 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 JefiFl{{uK<{*eFx delta 37770 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 literal 38160 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 V@==;xzbE`fuqVw7i%;O9{sY`?OvwNM literal 25126 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 «requirement def» FlightTimeRequirement - - + + diff --git a/docs/gallery/svg/PipelineActionFlowView.svg b/docs/gallery/svg/PipelineActionFlowView.svg index 5c89f0d9..9acd6d07 100644 --- a/docs/gallery/svg/PipelineActionFlowView.svg +++ b/docs/gallery/svg/PipelineActionFlowView.svg @@ -1,4 +1,4 @@ - + @@ -23,62 +23,62 @@ - - «action» - checkout - - «action» - restoreDependencies - - «action» - build - - «action» - unitTest - - «action» - integrationTest - - «action» - securityScan - - «action» - qualityGate - - «action» - deployStaging - - «action» - smokeTest + + «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 - - - - - - - - - - - - - - - - - - - + + «action» + rollback + + «action» + notifySuccess + + «action» + notifyFailure + + + + + + + + + + + + + + + + + + + diff --git a/docs/gallery/svg/WorkstationInterconnectionView.svg b/docs/gallery/svg/WorkstationInterconnectionView.svg index c0bcd1f3..21325f9a 100644 --- a/docs/gallery/svg/WorkstationInterconnectionView.svg +++ b/docs/gallery/svg/WorkstationInterconnectionView.svg @@ -50,25 +50,25 @@ - + - - + + - + - - + + - - + + diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs index 4cf64123..99fb539b 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/ChannelRouter.cs @@ -113,10 +113,12 @@ public static RouteResult RouteWithStatus( 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. + // 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, stub); - var routeTarget = StepOff(target, targetSide, stub); + 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)) @@ -128,7 +130,7 @@ public static RouteResult RouteWithStatus( xs, ys, IndexOf(xs, routeSource.X), IndexOf(ys, routeSource.Y), IndexOf(xs, routeTarget.X), IndexOf(ys, routeTarget.Y), - obstacles); + obstacles, c); if (path is not null) { @@ -197,6 +199,27 @@ private static IReadOnlyList Finalize( _ => 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. @@ -255,7 +278,8 @@ private static int IndexOf(double[] axis, double value) int startJ, int goalI, int goalJ, - IReadOnlyList obstacles) + IReadOnlyList obstacles, + double clearance) { var nx = xs.Length; var ny = ys.Length; @@ -286,8 +310,8 @@ private static int IndexOf(double[] axis, double value) foreach (var (ni, nj, nd) in Neighbors(ci, cj, nx, ny)) { - // Skip moves whose segment passes through an obstacle interior. - if (SegmentBlocked(xs, ys, ci, cj, ni, nj, obstacles)) + // Skip moves whose segment passes within the clearance of an obstacle. + if (SegmentBlocked(xs, ys, ci, cj, ni, nj, obstacles, clearance)) { continue; } @@ -343,8 +367,10 @@ private static double Heuristic(double[] xs, double[] ys, int i, int j, int goal Math.Abs(xs[i] - xs[goalI]) + Math.Abs(ys[j] - ys[goalJ]); /// - /// Determines whether the straight grid segment between two adjacent nodes passes through the - /// interior of any obstacle. + /// 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, @@ -353,7 +379,8 @@ private static bool SegmentBlocked( int j1, int i2, int j2, - IReadOnlyList obstacles) + IReadOnlyList obstacles, + double clearance) { if (j1 == j2) { @@ -363,8 +390,8 @@ private static bool SegmentBlocked( var xb = Math.Max(xs[i1], xs[i2]); foreach (var r in obstacles) { - if (r.Y < y && y < r.Y + r.Height && - Math.Max(xa, r.X) < Math.Min(xb, r.X + r.Width)) + 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; } @@ -378,8 +405,8 @@ private static bool SegmentBlocked( var yb = Math.Max(ys[j1], ys[j2]); foreach (var r in obstacles) { - if (r.X < x && x < r.X + r.Width && - Math.Max(ya, r.Y) < Math.Min(yb, r.Y + r.Height)) + 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; } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs index 33df46a9..3a0a5fd4 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Engine/LayeredLayoutEngine.cs @@ -74,7 +74,7 @@ public static LayeredResult Place( var layerGroups = GroupByLayer(layers); OrderLayers(layerGroups, acyclic); - return AssignCoordinates(nodes, layerGroups, layers, layerGap, nodeGap, padding); + return AssignCoordinates(nodes, layerGroups, layers, acyclic, layerGap, nodeGap, padding); } /// @@ -292,17 +292,30 @@ private static void SortByBarycenter(List layer, List adjacentLayer, L } } - /// Assigns absolute coordinates: layers stacked vertically, nodes spread horizontally. + /// 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]; @@ -314,26 +327,77 @@ private static LayeredResult AssignCoordinates( y += layerHeight + layerGap; } - // Horizontal positions within each layer, left to right. + // 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 x = padding; var layerHeight = layerGroups[l].Count == 0 ? 0.0 : layerGroups[l].Max(i => nodes[i].Height); foreach (var node in layerGroups[l]) { - // Centre each node vertically within its layer band. + var nodeX = cx[node] + shift - half[node]; var nodeY = layerY[l] + ((layerHeight - nodes[node].Height) / 2.0); - rects[node] = new PackedRect(x, nodeY, nodes[node].Width, nodes[node].Height); - x += nodes[node].Width + nodeGap; + rects[node] = new PackedRect(nodeX, nodeY, nodes[node].Width, nodes[node].Height); + maxRight = Math.Max(maxRight, nodeX + nodes[node].Width); } - - maxRight = Math.Max(maxRight, x - nodeGap); } var width = maxRight + padding; - // Total height: bottom of the last non-empty layer, plus padding. var lastLayerHeight = layerGroups[^1].Count == 0 ? 0.0 : layerGroups[^1].Max(i => nodes[i].Height); @@ -341,5 +405,48 @@ private static LayeredResult AssignCoordinates( 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/Internal/ActionFlowViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs index 013bfb64..5848a887 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs @@ -266,30 +266,32 @@ private static void AddStartAndDone( hasIncoming[to] = true; } - var centreX = layered.Width / 2.0; + var fallbackX = layered.Width / 2.0; - // Start marker above the first layer. + // 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(centreX, startY, MarkerSize, BadgeShape.FilledCircle, null)); - for (var i = 0; i < actions.Count; i++) + nodes.Add(new LayoutBadge(startX, startY, MarkerSize, BadgeShape.FilledCircle, null)); + foreach (var i in starts) { - if (!hasIncoming[i]) - { - nodes.Add(FlowLine(new Point2D(centreX, startY + (MarkerSize / 2.0)), - new Point2D(rects[i].X + (rects[i].Width / 2.0), rects[i].Y))); - } + nodes.Add(FlowLine(new Point2D(startX, startY + (MarkerSize / 2.0)), + new Point2D(rects[i].X + (rects[i].Width / 2.0), rects[i].Y))); } - // Done marker below the last layer. + // 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(centreX, doneY, MarkerSize, BadgeShape.Bullseye, null)); - for (var i = 0; i < actions.Count; i++) + nodes.Add(new LayoutBadge(doneX, doneY, MarkerSize, BadgeShape.Bullseye, null)); + foreach (var i in ends) { - if (!hasOutgoing[i]) - { - nodes.Add(FlowLine(new Point2D(rects[i].X + (rects[i].Width / 2.0), rects[i].Y + rects[i].Height), - new Point2D(centreX, doneY - (MarkerSize / 2.0)))); - } + 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)))); } } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs index d14714a4..b26cacc2 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs @@ -265,18 +265,36 @@ private static int AddPortsAndConnectors( connSlotPerPart[b].Add(c); } - // Assign port placements per part and index them by connection. + // 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]; - nodes.Add(new LayoutPort(placements[k].CentreX, placements[k].CentreY, placements[k].Side, null)); + 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++) @@ -320,6 +338,65 @@ private static int AddPortsAndConnectors( 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/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs index 7e06cc0b..c2ec9047 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/Engine/ChannelRouterTests.cs @@ -182,6 +182,31 @@ public void RouteWithStatus_TargetEnclosedByObstacle_ReportsCrossed() 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. /// @@ -249,4 +274,20 @@ private static bool SegmentCrossesRect(Point2D a, Point2D b, Rect r) 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)); + } } From 45c9cd74d628050de6f2a838a73ee1f3acaf2489 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 23:03:37 -0400 Subject: [PATCH 14/14] docs: align requirements/design/verification with the rendering subsystem Add per-unit and subsystem compliance documentation for the layout and rendering implementation, which previously had only data-model-level docs: - Declare the Layout Engine and Internal sub-subsystems and their units in docs/design/introduction.md - Per-unit requirements/design/verification for the 5 layout engines (ChannelRouter, ForceDirectedEngine, PortAssigner, LayeredLayoutEngine, ContainmentPacker), the 7 view strategies + LayoutWarnings, ConnectorLabelPlacer, and the Rendering DiagramTypeRouter; plus Engine/Internal/Rendering-Internal subsystem docs - Unit requirements state observable behavior (WHAT); algorithmic detail (A*/clearance-retry routing, stub capping, force-directed/layered placement, collision-aware label placement, dispatch order) lives only in the design docs - Wire all new files into requirements.yaml and the design/verification definition.yaml (also recovering the previously-uncompiled semantic/layout/ rendering design and verification chapters) - Add per-unit and subsystem ReviewMark review-sets per reviewmark-usage.md - Trim the outdated embedded GeneralViewLayoutStrategy section from layout.md Validated locally: ReqStream --enforce (all 52 new requirements trace to passing tests), ReviewMark --lint, build/tests (net8/9/10), and lint.ps1 all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .reviewmark.yaml | 246 ++++++++++++++++++ docs/design/definition.yaml | 35 +++ docs/design/introduction.md | 27 +- docs/design/sysml2-tools-core/layout.md | 59 ++--- .../layout/connector-label-placer.md | 53 ++++ .../design/sysml2-tools-core/layout/engine.md | 36 +++ .../layout/engine/channel-router.md | 67 +++++ .../layout/engine/containment-packer.md | 57 ++++ .../layout/engine/force-directed-engine.md | 59 +++++ .../layout/engine/layered-layout-engine.md | 60 +++++ .../layout/engine/port-assigner.md | 51 ++++ .../sysml2-tools-core/layout/internal.md | 41 +++ .../action-flow-view-layout-strategy.md | 67 +++++ .../internal/browser-view-layout-strategy.md | 54 ++++ .../internal/general-view-layout-strategy.md | 74 ++++++ .../internal/grid-view-layout-strategy.md | 52 ++++ .../interconnection-view-layout-strategy.md | 70 +++++ .../layout/internal/layout-warnings.md | 38 +++ .../internal/sequence-view-layout-strategy.md | 58 +++++ .../state-transition-view-layout-strategy.md | 71 +++++ .../sysml2-tools-core/rendering/internal.md | 27 ++ .../rendering/internal/diagram-type-router.md | 41 +++ .../layout/connector-label-placer.yaml | 41 +++ .../sysml2-tools-core/layout/engine.yaml | 45 ++++ .../layout/engine/channel-router.yaml | 67 +++++ .../layout/engine/containment-packer.yaml | 81 ++++++ .../layout/engine/force-directed-engine.yaml | 61 +++++ .../layout/engine/layered-layout-engine.yaml | 52 ++++ .../layout/engine/port-assigner.yaml | 50 ++++ .../sysml2-tools-core/layout/internal.yaml | 60 +++++ .../action-flow-view-layout-strategy.yaml | 42 +++ .../browser-view-layout-strategy.yaml | 32 +++ .../general-view-layout-strategy.yaml | 76 ++++++ .../internal/grid-view-layout-strategy.yaml | 32 +++ .../interconnection-view-layout-strategy.yaml | 42 +++ .../layout/internal/layout-warnings.yaml | 32 +++ .../sequence-view-layout-strategy.yaml | 42 +++ ...state-transition-view-layout-strategy.yaml | 53 ++++ .../sysml2-tools-core/rendering/internal.yaml | 35 +++ .../internal/diagram-type-router.yaml | 38 +++ docs/verification/definition.yaml | 35 +++ .../layout/connector-label-placer.md | 27 ++ .../sysml2-tools-core/layout/engine.md | 36 +++ .../layout/engine/channel-router.md | 39 +++ .../layout/engine/containment-packer.md | 38 +++ .../layout/engine/force-directed-engine.md | 33 +++ .../layout/engine/layered-layout-engine.md | 34 +++ .../layout/engine/port-assigner.md | 31 +++ .../sysml2-tools-core/layout/internal.md | 34 +++ .../action-flow-view-layout-strategy.md | 31 +++ .../internal/browser-view-layout-strategy.md | 27 ++ .../internal/general-view-layout-strategy.md | 38 +++ .../internal/grid-view-layout-strategy.md | 28 ++ .../interconnection-view-layout-strategy.md | 31 +++ .../layout/internal/layout-warnings.md | 27 ++ .../internal/sequence-view-layout-strategy.md | 29 +++ .../state-transition-view-layout-strategy.md | 34 +++ .../sysml2-tools-core/rendering/internal.md | 30 +++ .../rendering/internal/diagram-type-router.md | 32 +++ requirements.yaml | 18 ++ 60 files changed, 2810 insertions(+), 46 deletions(-) create mode 100644 docs/design/sysml2-tools-core/layout/connector-label-placer.md create mode 100644 docs/design/sysml2-tools-core/layout/engine.md create mode 100644 docs/design/sysml2-tools-core/layout/engine/channel-router.md create mode 100644 docs/design/sysml2-tools-core/layout/engine/containment-packer.md create mode 100644 docs/design/sysml2-tools-core/layout/engine/force-directed-engine.md create mode 100644 docs/design/sysml2-tools-core/layout/engine/layered-layout-engine.md create mode 100644 docs/design/sysml2-tools-core/layout/engine/port-assigner.md create mode 100644 docs/design/sysml2-tools-core/layout/internal.md create mode 100644 docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md create mode 100644 docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md create mode 100644 docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md create mode 100644 docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md create mode 100644 docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md create mode 100644 docs/design/sysml2-tools-core/layout/internal/layout-warnings.md create mode 100644 docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md create mode 100644 docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md create mode 100644 docs/design/sysml2-tools-core/rendering/internal.md create mode 100644 docs/design/sysml2-tools-core/rendering/internal/diagram-type-router.md create mode 100644 docs/reqstream/sysml2-tools-core/layout/connector-label-placer.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/engine.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/engine/channel-router.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/engine/containment-packer.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/engine/force-directed-engine.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/engine/layered-layout-engine.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/engine/port-assigner.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml create mode 100644 docs/reqstream/sysml2-tools-core/rendering/internal.yaml create mode 100644 docs/reqstream/sysml2-tools-core/rendering/internal/diagram-type-router.yaml create mode 100644 docs/verification/sysml2-tools-core/layout/connector-label-placer.md create mode 100644 docs/verification/sysml2-tools-core/layout/engine.md create mode 100644 docs/verification/sysml2-tools-core/layout/engine/channel-router.md create mode 100644 docs/verification/sysml2-tools-core/layout/engine/containment-packer.md create mode 100644 docs/verification/sysml2-tools-core/layout/engine/force-directed-engine.md create mode 100644 docs/verification/sysml2-tools-core/layout/engine/layered-layout-engine.md create mode 100644 docs/verification/sysml2-tools-core/layout/engine/port-assigner.md create mode 100644 docs/verification/sysml2-tools-core/layout/internal.md create mode 100644 docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md create mode 100644 docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md create mode 100644 docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md create mode 100644 docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md create mode 100644 docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md create mode 100644 docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md create mode 100644 docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md create mode 100644 docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md create mode 100644 docs/verification/sysml2-tools-core/rendering/internal.md create mode 100644 docs/verification/sysml2-tools-core/rendering/internal/diagram-type-router.md 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/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/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 /// Renders a as an SVG <text> element with /// text-anchor derived from . 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); + } +} From 2e6ac795406c80d01bbbc5e09a228badbb556595 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 27 Jun 2026 20:41:24 -0400 Subject: [PATCH 12/14] feat: distribute state-transition connectors across edge anchor points Group transition endpoints by box edge and lay them out at distinct points instead of all stacking on the side midpoint. Endpoints are ordered by their counterpart position to minimize crossings, then adjacent same-direction endpoints are merged into shared anchor points. This keeps incoming arrowheads from coinciding with outgoing transitions, so every connector's direction is unambiguous. - Replace AnchorToward midpoint anchoring with SideToward + PointOnSide + OrderKey - Regenerate Elevator gallery images - Add bidirectional-edge anchor-distinctness test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../png/ElevatorStateTransitionView.png | Bin 28366 -> 28590 bytes .../svg/ElevatorStateTransitionView.svg | 18 +-- .../StateTransitionViewLayoutStrategy.cs | 144 +++++++++++++++--- .../StateTransitionViewLayoutStrategyTests.cs | 45 ++++++ 4 files changed, 179 insertions(+), 28 deletions(-) diff --git a/docs/gallery/png/ElevatorStateTransitionView.png b/docs/gallery/png/ElevatorStateTransitionView.png index a63c1215cb5b0ea085098b0e16343696473e0d9d..62dcac2045e799073f87771a58dad8b6b5fcc5c4 100644 GIT binary patch literal 28590 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 - - + + - - + + - - [callReceived] - [doorsClosed] + + [callReceived] + [doorsClosed] [atFloor] [atFloor] [doorsAreOpen] - [timeout] + [timeout] [goingDown] - [idleTimeout] + [idleTimeout] diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs index 4cf090a9..c8646fe2 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs @@ -241,14 +241,92 @@ private static void AddInitialMarker(Rect first, List nodes) /// Adds transition edges (with guard labels) between state boxes, returning the number of edges /// that could not be routed without crossing a state box. ///