diff --git a/.cspell.yaml b/.cspell.yaml index 1d64d51..02144f9 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -164,6 +164,8 @@ words: - normalised - optimise - optimised + - opto + - Opto - quadtree - quantisation - Quantisation diff --git a/ROADMAP.md b/ROADMAP.md index 88181a3..c2bc888 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -51,6 +51,24 @@ primitives (bar, diamond, pentagon, note). `LayoutActivation`/`LayoutBand` alrea **Visual gate:** sequence shows activation bars + a fragment; action flow shows a fork/join and a decision/merge with correct shapes. +### Interconnection View: genuine cross-boundary connector routing + +`InterconnectionViewLayoutStrategy` now resolves a connection endpoint's full dotted reference +(e.g. `board.cpu`) for its port **label**, so a cross-boundary reference shows the true nested +target's name instead of discarding it. The connector line itself, however, still terminates at +the containing part's own box boundary rather than routing all the way into the nested container +to the inner part — genuine cross-boundary routing would require restructuring +`LayOutInterior`'s per-level independent `LayeredPlacement.Place` calls into one connected nested +`LayoutGraph`/`LayoutGraphNode.Children` for the affected subtree, using the companion +`DemaConsulting.Rendering` package's boundary/delegation-port (`HierarchyHandling.Recursive`) +support end-to-end, instead of the strategy's current two-independent-layouts-stitched-together +recursion. + +**Scope:** `InterconnectionViewLayoutStrategy`'s `LayOutInterior`/`CollectParts` recursion; +possibly a new `LayeredPlacement` entry point that builds a genuinely nested `LayoutGraph`. +**Visual gate:** `connect psu to board.cpu;` renders a connector line that visually terminates on +the inner `cpu` box, not the `board` container's boundary. + ### View `filter [];` expression evaluation `GeneralViewLayoutStrategy` now scopes a rendered diagram to a view's `expose <...>;` subject 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 index 86a7df9..3aaca47 100644 --- 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 @@ -101,7 +101,15 @@ were not depth-truncated — is decided later, in `BuildGraph`. ###### `BuildGraph(groups, modelEdges, theme, depthLimit)` -Builds the single input `LayoutGraph`. Each package becomes a folder container node +Builds the single input `LayoutGraph`, setting `CoreOptions.MergeParallelEdges` to `false` on the +root graph so multiple distinct model relationship edges that happen to share the same source and +target (for example two attributes of the same type, or a redefinition edge that coincides with +another edge between the same two definitions) are never collapsed by the bundled layered +algorithm's default parallel-edge merging — every distinct model relationship this strategy adds +remains its own visible, independently-routed edge, unlike `LayeredPlacement`'s helper (used by the +flat view strategies), which defaults to the algorithm's own merge-by-default behavior unless a +caller opts out (see `LayeredPlacement`'s design documentation). Each package becomes a folder +container node (`Shape = Folder`, `Keyword = "package"`, `Label` the simple package name, `TitleHeight` set from `BoxMetrics.TitleAreaHeight` so the hierarchical engine reserves the exact title band the renderer will draw) holding its definitions as leaf nodes under `folder.Children`; the folder's own 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 index 2d0e87b..df1f5a1 100644 --- 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 @@ -11,12 +11,12 @@ container box for the host definition. ##### Data Model `InterconnectionViewLayoutStrategy` has no instance state; all input arrives through the -`BuildLayout` parameters. Layout constants (`MinPartWidth`, `CharWidthFactor`, `MinPortSlot`, -`ConnectorClearance`) are declared as `private const double` fields. Three private records carry -intermediate data: `PartItem` (a nested part usage with its computed box size, typing, and — when -the part is a container — its pre-laid-out `InnerContent`), `ConnPair` (a resolved binary -connection between two nested-part indices), and `InteriorLayout` (the full container size and -content produced by laying out one definition's interior). +`BuildLayout` parameters. Layout constants (`MinPartWidth`, `CharWidthFactor`) are declared as +`private const double` fields. Three private records carry intermediate data: `PartItem` (a nested +part usage with its computed box size, typing, and — when the part is a container — its +pre-laid-out `InnerContent`), `ConnPair` (a resolved binary connection between two nested-part +indices, together with the optional port-name label for each end), and `InteriorLayout` (the full +container size and content produced by laying out one definition's interior). ##### Key Methods @@ -25,9 +25,10 @@ content produced by laying out one definition's interior). Entry point. Resolves the view's `expose` scope via `ExposeScopeResolver.ResolveExposedScope`, selects the root part definition via `FindRoot(workspace, scope)`, builds the container-definition index via `BuildDefinitionIndex`, lays out the root's interior via `LayOutInterior` (threading -`scope` through every recursive call), and assembles the root container box plus the interior -content into the `LayoutTree`. Returns a minimal 200×100 empty `LayoutTree` when no root or no -parts are found. +`scope` through every recursive call), and assembles the root container box with the interior +content nested as that box's own `Children` (mirroring the nesting `MakePartBox` already uses for a +container part, so the root box is never a bare sibling of its own content) into the `LayoutTree`. +Returns a minimal 200×100 empty `LayoutTree` when no root or no parts are found. ###### Recursive nested layout (`LayOutInterior`, `CollectParts`, `BuildDefinitionIndex`) @@ -51,26 +52,70 @@ node by its parent, which is laid out with the **same** flat placement. `offsetX = LabelPadding × 2`, `offsetY = TitleAreaHeight(hasLabel, hasKeyword) + LabelPadding × 2`, `containerWidth = TotalWidth + offsetX × 2`, and `containerHeight = TotalHeight + offsetY + LabelPadding × 2`. A container box therefore bounds its - laid-out children plus its title and insets, and the parent treats that size as atomic. Because a - node is only ever grown via `Math.Max(height, degreeMinHeight)`, the reserved interior never - shrinks below what the children need; any extra port-slot height is empty space at the bottom of - the container. + laid-out children plus its title and insets, and the parent treats that size as atomic. Box + height itself is no longer scaled by hand from a per-port minimum-slot heuristic: each part's + ports are modeled as named `LayoutGraphPort`s on `LayeredPlacement.PlaceWithPorts`'s input graph, + so the layered algorithm itself grows a box, when needed, to keep every incident connection's + port visually distinct. Every part is also passed to `PlaceWithPorts` with `HasLabel: true, + HasKeyword: true` (every `PartItem` always carries a non-empty name and a `"part"` keyword, + mirroring the `hasLabel: true, hasKeyword: true` convention already used for `TitleAreaHeight` + above), which activates the engine's automatic title-vs-side-port reservation so no port is ever + placed across the box's own title band. - **Positioning.** `MakePartBox` builds a leaf box with empty `Children` (unchanged), and a container box whose `Children` are its `InnerContent` translated from the child's local origin `(0, 0)` to the box's absolute top-left by `TranslateNodes`, which recursively shifts box positions (and their nested children), port centres, and connector waypoints. The interior was laid out reserving its own title area, so the inner part boxes land below the container's "name : Type" title, inside its border. Box `Depth` increases by one per level (the renderer - indexes `DepthFillColors` by modulo, so any depth is safe). + indexes `DepthFillColors` by modulo, so any depth is safe). The root container box mirrors this + same pattern one level up: `BuildLayout` nests the root's own interior content as the root box's + `Children` rather than as flat top-level siblings of the root box — the root sits at the same + origin `(0, 0)` its interior content is already positioned relative to, so no translation is + needed there. - **No-op invariant.** When no part is a container, every `PartItem.InnerContent` is `null`, `MakePartBox` emits exactly the non-recursive leaf box with empty `Children`, and the placement call, offsets, ports, and lines are identical to the single-level layout — single-level output is byte-identical. -- **Cross-boundary limitation.** Connection endpoints resolve to **parent-level** part indices via - the head segment of the dotted reference, so a reference such as `connect psu to board.cpu` - terminates on the `board` container boundary rather than routing to the inner `cpu` box. This is - the known `SEPARATE_CHILDREN` limitation (no true cross-boundary routing); gallery models avoid - relying on cross-boundary endpoints. +- **Cross-boundary resolution.** `ResolveEndpoint` resolves the **full** dotted endpoint reference, + not just its head segment: the head segment still identifies the parent-level part index, and any + remaining dotted segment(s) (e.g. `cpu` in `board.cpu`, or `encoder` in `StepperMotorX.encoder`) is + captured as that endpoint's port-name label and flows through `ConnPair.LabelA`/`LabelB` into the + emitted `LayoutPort.ExternalLabel`. This means a reference into a nested part (`connect psu to + board.cpu`) now shows the true target name (`cpu`) on the port, rather than the pre-fix behavior of + silently discarding it. The connector itself is still **routed only to the container's own + boundary** — it does not continue into the container's interior to physically terminate on the + inner `cpu` box. Achieving that (a genuine boundary/delegation-port anchor shared between the + outer connector and an inner one, via the companion library's `HierarchyHandling.Recursive` + support) would require restructuring `LayOutInterior`'s per-level independent + `LayeredPlacement.PlaceWithPorts` calls into one connected nested `LayoutGraph`/ + `LayoutGraphNode.Children` for the affected subtree, which is a materially larger architectural + change than this feature makes; it remains a known, documented limitation. `ResolveEndpoint` + captures **everything** after the first dotted segment as the label, however many levels deep + (e.g. `board.sub.cpu` yields the label `sub.cpu`, not just `sub`); only the _routing_ — never the + label text — stops at the container's boundary regardless of path depth. + +##### Port Labeling + +Each connection endpoint that resolves to a nested part is requested as a named +`LayeredPlacement.EdgePortRef` on that endpoint's `PortEdge`, carrying the real SysML port-name +segment (from `ConnPair.LabelA`/`LabelB`; see _Cross-boundary resolution_ above and +`ResolveEndpoint` below) as the port's `ExternalLabel`. `LayeredPlacement.PlaceWithPorts` creates +the corresponding `LayoutGraphPort` and returns the engine-placed `LayoutPort` with that label +already attached — the strategy only translates the returned port's `CentreX`/`CentreY` by the +container offset before adding it to the interior's content. A bare endpoint reference with no +dotted port segment (e.g. `connect psu to board`) yields a `null` label (a port is still created +and placed; only its label is absent), preserving the pre-fix rendering (no label shown) for that +connector end. + +##### Parallel Connection Preservation + +`LayOutInterior` calls `LayeredPlacement.PlaceWithPorts` (which unconditionally disables +parallel-edge merging — see `docs/design/sysml2-tools-core/layout/internal/layered-placement.md`), +so multiple distinct SysML connections between the same two parts (e.g. separate +`power`/`encoder`/`sensor` connections wired between the same controller and motor, as seen in a +real 3-axis-gantry wiring model) are never collapsed onto one shared routed polyline: each +connection keeps its own independently-routed connector with distinct waypoints (a separate +parallel lane), and its own pair of labeled ports. ###### `FindRoot(workspace, scope)` @@ -89,10 +134,12 @@ break ties among equally specific candidates; this ordering does not apply when `CollectParts` gathers the root's nested `part` usages, sizing each box from its `name : Type` label, additionally excluding — when a scope is resolved — any part feature whose qualified name fails `ExposeScopeResolver.IsInSubjectScope`. `ResolveConnections` maps each binary connection's -dotted endpoint references to nested-part indices by matching the first segment against the -(possibly narrowed) part names, keeping only distinct, resolvable pairs; a connection whose -endpoint was excluded by scoping simply fails to resolve and is dropped by this existing -endpoint-lookup logic — no separate edge-side scoping is needed. +dotted endpoint references to nested-part indices and port-name labels via `ResolveEndpoint` +(matching the first dotted segment against the (possibly narrowed) part names and capturing any +remaining segment as the port label — see _Cross-boundary resolution_ above), keeping only +distinct, resolvable pairs; a connection whose endpoint was excluded by scoping simply fails to +resolve and is dropped by this existing endpoint-lookup logic — no separate edge-side scoping is +needed. ##### Expose Scoping @@ -116,13 +163,16 @@ every candidate and `CollectParts` keeps every part, unchanged from the pre-scop ###### Placement and routing -Placement and routing are delegated to `LayeredPlacement.Place` with a left-to-right flow direction. -Nested-container recursion is driven at the strategy level (see _Recursive nested layout_ above); -each level calls the same flat placement helper. The strategy passes the collected part boxes and -resolved connection pairs as plain geometric input. `LayeredPlacement` delegates to the off-the-shelf -`DemaConsulting.Rendering.Layout` layered algorithm and returns placed rectangles and connector -waypoints, with disconnected components packed without overlap. The total canvas extent is derived -from the placed box and waypoint geometry. +Placement and routing are delegated to `LayeredPlacement.PlaceWithPorts` with a left-to-right flow +direction; parallel-edge merging is unconditionally disabled by that method (see _Parallel +Connection Preservation_ above). Nested-container recursion is driven at the strategy level (see +_Recursive nested layout_ above); each level calls the same flat placement helper. The strategy +passes the collected part boxes and resolved connection pairs — each carrying an `EdgePortRef` for +every endpoint — as plain geometric input. `LayeredPlacement` delegates to the off-the-shelf +`DemaConsulting.Rendering.Layout` layered algorithm and returns placed rectangles, connector +waypoints, and correlated placed ports, with disconnected components packed without overlap and +port spacing/box growth resolved by the engine itself. The total canvas extent is derived from the +placed box and waypoint geometry. The strategy then shifts the placed content to sit inside the container box and extends the container so every connector waypoint is enclosed, without ever moving a box. diff --git a/docs/design/sysml2-tools-core/layout/internal/layered-placement.md b/docs/design/sysml2-tools-core/layout/internal/layered-placement.md index 659797e..6fb1d14 100644 --- a/docs/design/sysml2-tools-core/layout/internal/layered-placement.md +++ b/docs/design/sysml2-tools-core/layout/internal/layered-placement.md @@ -12,19 +12,36 @@ by index. ##### Data Model -`LayeredPlacement` is a static class with no instance state. It exposes one nested immutable record: +`LayeredPlacement` is a static class with no instance state. It exposes nested immutable records: -- `PlacedLayout` — the result of a placement, with: +- `PlacedLayout` — the result of `Place`, with: - `Rects` — the placed box rectangles (`Rect`), one per input node, in input-node order. - `EdgePolylines` — the routed orthogonal connector polylines (each a list of `Point2D`), one per input edge, in input-edge order, already oriented source-to-target. - `Width`, `Height` — the overall content extent in logical pixels. +- `PlacedPortLayout` — the result of `PlaceWithPorts`, with the same `Rects`/`EdgePolylines`/ + `Width`/`Height` shape as `PlacedLayout`, plus: + - `EdgePorts` — the placed source/target ports (`(LayoutPort? Source, LayoutPort? Target)`), one + pair per input edge, in input-edge order; an element is `null` when the corresponding + `PortEdge.SourcePort`/`TargetPort` was itself `null` (that endpoint attached to the plain node + instead of a named port). +- `EdgePortRef` — requests that an edge endpoint attach through a named port rather than to its + node as a whole, carrying an optional `Label` rendered as that port's `LayoutGraphPort.ExternalLabel`. +- `PortEdge` — a directed edge (`From`, `To`) with an optional `SourcePort`/`TargetPort` + `EdgePortRef` at either endpoint; a `null` ref means that endpoint attaches directly to the node. + +`Place`'s `nodes` parameter is a plain `(double Width, double Height)` tuple list. +`PlaceWithPorts`'s `nodes` parameter is instead `(double Width, double Height, bool HasLabel, bool +HasKeyword)`: the two extra flags carry no SysML semantics of their own — they exist solely to tell +`PlaceWithPorts` whether the caller's node will render a title, so it can set the created +`LayoutGraphNode.Label`/`.Keyword` to a non-null sentinel and activate the engine's automatic +title-vs-side-port reservation for that node (see step 2 below). `Rect` and `Point2D` are geometric value types provided by the `DemaConsulting.Rendering` package. ##### Key Methods -###### `Place(nodes, edges, direction)` +###### `Place(nodes, edges, direction, mergeParallelEdges = true)` Places sized nodes and directed edges by delegating to the public layout engine facade: @@ -33,14 +50,19 @@ Places sized nodes and directed edges by delegating to the public layout engine width and height, keyed by its ordinal index) and one edge per input edge (keyed by its ordinal index) between the referenced graph nodes. 3. Sets the requested `LayoutFlowDirection` directly on the graph via - `graph.Set(CoreOptions.Direction, direction)` and calls `LayoutEngine.Layout(graph)`, obtaining - a laid-out `LayoutTree`. The direction must be set on the graph itself (not passed through a - `LayoutOptions` instance) because the facade always seeds its internal cascade with an empty - `LayoutOptions`, honoring only settings declared directly on the graph. The graph built here is - always flat (no container nodes), so the facade's default `hierarchical` algorithm is guaranteed - byte-for-byte identical to the bundled `layered` algorithm applied directly — using the public - facade rather than instantiating `LayeredLayoutAlgorithm` directly costs nothing and keeps this - helper aligned with the package's intended entry point. + `graph.Set(CoreOptions.Direction, direction)`. When `mergeParallelEdges` is `false`, also sets + `graph.Set(CoreOptions.MergeParallelEdges, false)` on the graph — the additive optional + parameter defaults to `true`, which skips this call entirely and keeps the method's original, + unconditional behavior for every pre-existing call site + (`ActionFlowViewLayoutStrategy`/`StateTransitionViewLayoutStrategy`) byte-for-byte identical. + Calls `LayoutEngine.Layout(graph)`, obtaining a laid-out `LayoutTree`. Both settings must be set + on the graph itself (not passed through a `LayoutOptions` instance) because the facade always + seeds its internal cascade with an empty `LayoutOptions`, honoring only settings declared + directly on the graph. The graph built here is always flat (no container nodes), so the facade's + default `hierarchical` algorithm is guaranteed byte-for-byte identical to the bundled `layered` + algorithm applied directly — using the public facade rather than instantiating + `LayeredLayoutAlgorithm` directly costs nothing and keeps this helper aligned with the package's + intended entry point. 4. Reads the tree's `LayoutBox` nodes into `Rects` (in emitted order, which mirrors input-node order) and the tree's `LayoutLine` nodes into `EdgePolylines` (in emitted order, which mirrors input-edge order). @@ -49,18 +71,66 @@ Places sized nodes and directed edges by delegating to the public layout engine The algorithm emits exactly one placed box per input node and exactly one routed connector per input edge, preserving the caller's ordering, and reverses back edges internally so every returned -polyline runs source-to-target. +polyline runs source-to-target. When `mergeParallelEdges` is `false`, multiple edges between the +same pair of nodes are each still emitted as their own polyline (as they already were), but with +genuinely distinct routed waypoints instead of all sharing one route — see +`CoreOptions.MergeParallelEdges` in the companion package. + +###### `PlaceWithPorts(nodes, edges, direction)` + +A second, additive entry point for callers that need an edge to attach through a specific, named +connection point on a node's boundary — for example so a caller can label the exact port a +connection uses, and let the layout engine itself resolve that port's side, spacing, and any +resulting box growth, instead of computing box heights or port positions by hand. Each input node +also carries `HasLabel`/`HasKeyword` flags stating whether it will render a title, so the engine's +automatic title-vs-side-port reservation activates for it and no port lands across its own title +band. `Place` itself is completely unmodified; `PlaceWithPorts` does not call it and does not share +its code path beyond the identical `LayoutGraph`/`LayoutGraphNode` construction pattern. + +1. Validates that `nodes` and `edges` are non-null. +2. Builds a `LayoutGraph`, adding one `LayoutGraphNode` per input node exactly as `Place` does, then + — a step `Place` does not perform — sets that node's `LayoutGraphNode.Label`/`.Keyword` to a + non-null sentinel (`string.Empty`) whenever the input node's `HasLabel`/`HasKeyword` is `true`. + The engine's layered algorithm reserves a title band above a node's ports purely based on whether + `Label`/`Keyword` are non-null (never their text), so this sentinel is the minimal signal needed + to keep ports clear of a titled box's own header row. +3. For each input `PortEdge`, resolves its `ILayoutConnectable` source and target: when + `SourcePort`/`TargetPort` is non-null, calls `graphNodes[from].Ports.AddPort("{edgeIndex}-a")` (or + `"{edgeIndex}-b"` for the target), sets the new `LayoutGraphPort.ExternalLabel` to the requested + `EdgePortRef.Label`, and uses that port as the endpoint; otherwise uses the plain node. The + `"{edgeIndex}-a"`/`"{edgeIndex}-b"` naming scheme is unique per owning node because the edge index + is unique across the whole input, so multiple connections into the same node never collide. Each + created `LayoutGraphPort` reference is remembered (per edge, per end) for the correlation step + below. Calls `graph.AddEdge(edgeIndex, source, target)` with the resolved endpoints. +4. Sets `CoreOptions.Direction` to the requested `direction` and unconditionally sets + `CoreOptions.MergeParallelEdges` to `false` — unlike `Place`'s optional parameter, this method has + no parameter for it, since every current caller needs parallel-edge preservation. Calls + `LayoutEngine.Layout(graph)`. +5. Reads the tree's `LayoutBox` nodes into `Rects` and `LayoutLine` nodes into `EdgePolylines` + exactly as `Place` does. +6. Builds `EdgePorts[e]` by scanning the tree's `LayoutPort` nodes for the one whose + `LayoutPort.SourcePort` is reference-equal (`ReferenceEquals`) to the `LayoutGraphPort` created + for edge `e`'s source (respectively target) in step 3; `null` when no port was requested for that + end. `LayoutPort.SourcePort` exists specifically so a caller can recover, by reference identity, + which graph port produced a given placed anchor — its own XML documentation states this is its + intended purpose, since `ExternalLabel` is frequently `null` and so cannot itself serve as an + identity key. +7. Returns a `PlacedPortLayout` carrying the rectangles, polylines, correlated ports, and the tree's + overall `Width` and `Height`. ##### Error Handling -`Place` throws `ArgumentNullException` when `nodes` or `edges` is null. All other behavior is -delegated to the off-the-shelf layout engine; the helper adds no additional validation. +`Place` and `PlaceWithPorts` each throw `ArgumentNullException` when their `nodes` or `edges` +parameter is null. All other behavior is delegated to the off-the-shelf layout engine; the helper +adds no additional validation. ##### Dependencies - `DemaConsulting.Rendering` (OTS) — the layout intermediate representation (`LayoutGraph`, - `LayoutGraphNode`, `LayoutTree`, `LayoutBox`, `LayoutLine`), the geometric value types (`Rect`, - `Point2D`), and the layout option system (`CoreOptions.Direction`, `IPropertyHolder.Set`). + `LayoutGraphNode`, `LayoutGraphPort`, `LayoutGraphPortCollection`, `ILayoutConnectable`, + `LayoutTree`, `LayoutBox`, `LayoutLine`, `LayoutPort`), the geometric value types (`Rect`, + `Point2D`), and the layout option system (`CoreOptions.Direction`, `CoreOptions.MergeParallelEdges`, + `IPropertyHolder.Set`). - `DemaConsulting.Rendering.Layout` (OTS) — the `LayoutEngine.Layout(LayoutGraph)` facade that resolves and runs the appropriate bundled algorithm (the bundled `layered` algorithm, for the flat graphs built here) to perform the actual ELK-style layered placement and orthogonal @@ -69,9 +139,14 @@ delegated to the off-the-shelf layout engine; the helper adds no additional vali ##### Callers -The view layout strategies that arrange nodes with the layered algorithm call -`LayeredPlacement.Place`: `InterconnectionViewLayoutStrategy` (with a left-to-right direction), and -`ActionFlowViewLayoutStrategy` and `StateTransitionViewLayoutStrategy` (with a top-to-bottom -direction). `GeneralViewLayoutStrategy` builds and places its own `LayoutGraph` directly (via -`HierarchicalLayoutAlgorithm`, since its graph is nested with package-folder containers) rather -than going through this helper. +The view layout strategies that arrange nodes with the layered algorithm call either `Place` or +`PlaceWithPorts`: `InterconnectionViewLayoutStrategy` calls `PlaceWithPorts` (with a left-to-right +direction), so every connection endpoint attaches through a named, labeled port and the engine +itself resolves port sides, spacing, and any resulting box growth, while distinct parallel SysML +connections between the same two parts still render as separate independently-routed connectors +(parallel-edge merging is unconditionally disabled by `PlaceWithPorts`). `ActionFlowViewLayoutStrategy` +and `StateTransitionViewLayoutStrategy` call `Place` (with a top-to-bottom direction, leaving +`mergeParallelEdges` at its default `true`, unchanged from before this parameter existed). +`GeneralViewLayoutStrategy` builds and places its own `LayoutGraph` directly (via +`HierarchicalLayoutAlgorithm`, since its graph is nested with package-folder containers) rather than +going through this helper. diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 6ad8497..9b8fed6 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -191,6 +191,23 @@ SVG: [`svg/ComputerSystemInterconnectionView.svg`](svg/ComputerSystemInterconnec --- +## 9. Multi-Port Interconnection View — Motor/Controller Rig + +Shows multiple independent, named-port connections between the same two parts: a +`motor` is wired to its `controller` by three separate connections (`powerLink`, +`encoderLink`, `thermalLink`), each between its own distinct pair of ports. This +demonstrates that the interconnection layout keeps every parallel connector between +the same two boxes visually and structurally distinct — rather than collapsing them +onto one shared route — and labels each end with its own SysML port name, with the +boxes auto-sized to keep every port label clear of the box's own title. + +Model: [`models/09-motor-controller-multi-port.sysml`](models/09-motor-controller-multi-port.sysml) · +SVG: [`svg/MotorRigInterconnectionView.svg`](svg/MotorRigInterconnectionView.svg) + +![Motor Rig Interconnection View](png/MotorRigInterconnectionView.png) + +--- + ## View coverage | # | View type | Example system | Status | @@ -203,4 +220,5 @@ SVG: [`svg/ComputerSystemInterconnectionView.svg`](svg/ComputerSystemInterconnec | 6 | Grid View | Vehicle Taxonomy | ✅ | | 7 | Browser View | Avionics System | ✅ | | 8 | Nested Interconnection View | Computer System | ✅ | -| 9 | Geometry View | — | Deferred (requires spatial coordinate data) | +| 9 | Multi-Port Interconnection View | Motor/Controller Rig | ✅ | +| 10 | Geometry View | — | Deferred (requires spatial coordinate data) | diff --git a/docs/gallery/models/09-motor-controller-multi-port.sysml b/docs/gallery/models/09-motor-controller-multi-port.sysml new file mode 100644 index 0000000..694434f --- /dev/null +++ b/docs/gallery/models/09-motor-controller-multi-port.sysml @@ -0,0 +1,40 @@ +package MotorControllerRig { + + // ===== Port definitions ===== + port def PowerPort; + port def EncoderPort; + port def ThermalPort; + + // ===== Core part definitions ===== + part def Controller { + port power : PowerPort; + port encoderFeedback : EncoderPort; + port thermalSense : ThermalPort; + } + + part def Motor { + port powerIn : PowerPort; + port encoderOut : EncoderPort; + port thermalOut : ThermalPort; + } + + // Three independent, named-port connections between the same two parts: a motor + // is wired to its controller by a power feed, an encoder feedback line, and a + // thermal sense line, each its own distinct connection rather than a single bundled + // link. This demonstrates that the interconnection layout keeps every parallel + // connector between the same two boxes visually and structurally distinct, and + // labels each end with its own SysML port name rather than merging them into one + // shared route. + part def MotorRig { + part controller : Controller; + part motor : Motor; + + connection powerLink connect controller.power to motor.powerIn; + connection encoderLink connect motor.encoderOut to controller.encoderFeedback; + connection thermalLink connect motor.thermalOut to controller.thermalSense; + } + + view def MotorRigInterconnectionView { + render asInterconnectionDiagram; + } +} diff --git a/docs/gallery/png/MotorRigInterconnectionView.png b/docs/gallery/png/MotorRigInterconnectionView.png new file mode 100644 index 0000000..bd717ef Binary files /dev/null and b/docs/gallery/png/MotorRigInterconnectionView.png differ diff --git a/docs/gallery/svg/MotorRigInterconnectionView.svg b/docs/gallery/svg/MotorRigInterconnectionView.svg new file mode 100644 index 0000000..18aac4f --- /dev/null +++ b/docs/gallery/svg/MotorRigInterconnectionView.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «part def» + MotorRig + + «part» + controller : Controller + + «part» + motor : Motor + + power + + powerIn + + + encoderOut + + encoderFeedback + + + thermalOut + + thermalSense + + 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 index eb9d3a2..4533239 100644 --- 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 @@ -19,6 +19,7 @@ sections: observable content the view must provide. tests: - InterconnectionView_BuildLayout_PartsAndConnections_ProducesBoxesPortsAndLines + - InterconnectionView_BuildLayout_RootContent_IsNestedAsRootBoxChildren - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-Ports title: >- @@ -29,6 +30,7 @@ sections: observable anchor the connector lines are drawn between. tests: - InterconnectionView_BuildLayout_PartsAndConnections_ProducesBoxesPortsAndLines + - InterconnectionView_BuildLayout_HighConnectionDegreePart_BoxesDoNotOverlapAndPortsAreLabeled - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-Connectors title: >- @@ -49,6 +51,7 @@ sections: an observable property of the produced geometry the strategy must guarantee. tests: - InterconnectionView_BuildLayout_PartBoxes_DoNotOverlap + - InterconnectionView_BuildLayout_HighConnectionDegreePart_BoxesDoNotOverlapAndPortsAreLabeled - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-EmptyCanvas title: >- @@ -174,6 +177,50 @@ sections: tests: - InterconnectionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ParallelConnectorPreservation + title: >- + When two or more distinct connections exist between the same pair of nested parts, + InterconnectionViewLayoutStrategy shall preserve every connection as its own + independently-routed connector rather than collapsing them onto one shared route. + justification: | + A real-world wiring model may have several separate connections (for example a power + connection and an encoder connection) between the same two physical parts; visually + merging them into what looks like a single connector loses information the diagram exists + to communicate. Every parallel connection must remain visually distinct. + tests: + - InterconnectionView_BuildLayout_TwoConnectionsSamePair_ProducesTwoConnectorsWithoutException + - InterconnectionView_BuildLayout_ThreeParallelConnections_ProducesThreeDistinctConnectors + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-PortLabeling + title: >- + InterconnectionViewLayoutStrategy shall label each connector's ports with the SysML + port-name segment of the connection's endpoint reference, when present. + justification: | + A connection endpoint such as `StepperMotorX.encoder` names a specific port on the part, + not just the part itself; omitting that name (as the pre-fix rendering did) discards + information the diagram exists to show. Labeling the port with the real port name lets a + reader identify which physical connector a line represents. + tests: + - InterconnectionView_BuildLayout_ConnectionEndpointWithPortSegment_PortLabelReflectsSysmlPortName + - InterconnectionView_BuildLayout_ThreeParallelConnections_ProducesThreeDistinctConnectors + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-CrossBoundaryLabelResolution + title: >- + When a connection endpoint references a part nested inside a container part (a + cross-boundary reference such as `board.cpu`), InterconnectionViewLayoutStrategy shall + resolve the full dotted reference for the port's label, reflecting the true nested + target name, even though the connector itself still terminates at the container's own + boundary. + justification: | + Discarding everything after the first dotted segment (the pre-fix behavior) silently + mislabeled or unlabeled a cross-boundary connector, hiding which inner part it actually + targets. Resolving the full path for the label is achievable without the larger + architectural change genuine cross-boundary connector routing would require, and honestly + documents the remaining limitation (the connector still terminates at the container + boundary, not the inner part) rather than silently under-delivering. + tests: + - InterconnectionView_BuildLayout_CrossBoundaryEndpoint_LabelReflectsNestedTarget + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ScopedSpecificityTieBreak title: >- When more than one candidate root is relevant to the resolved expose scope, @@ -189,3 +236,18 @@ sections: tests: - InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_SelectsNestedDefinitionNotAncestor - InterconnectionView_BuildLayout_ExposeBothSameDepthSiblings_ScoreBreaksTieNotLength + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-PortTitleCollisionAvoidance + title: >- + InterconnectionViewLayoutStrategy shall ensure no part-box port anchor or label lands + within that box's own title area, even under a high incident-connection count. + justification: | + Each part box renders a two-line "«keyword» / name : type" title in its header row; a + port placed across that band visually collides with the title text, making both + unreadable. Flagging every part node as carrying a title when handed to the layered + algorithm (see LayeredPlacement's TitleAwarePorts requirement) activates the engine's + automatic title-vs-side-port reservation, so no port ever lands in the header row a + titled box renders — this holds regardless of how many connections are incident to a + part. + tests: + - InterconnectionView_BuildLayout_PartWithPorts_PortsNeverOverlapBoxTitleArea diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/layered-placement.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/layered-placement.yaml index 3b4755d..07f4857 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/layered-placement.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/layered-placement.yaml @@ -84,3 +84,59 @@ sections: - ActionFlowView_BuildLayout_ForwardChain_FlowsTopToBottomOrthogonally - StateTransitionView_BuildLayout_ForwardChain_FlowsTopToBottomOrthogonally - ActionFlowView_BuildLayout_Successions_FlowTopToBottom + + - id: SysML2Tools-Core-Layout-Internal-LayeredPlacement-MergeParallelEdgesOptOut + title: >- + LayeredPlacement shall support an opt-in `mergeParallelEdges: false` request that + disables the underlying algorithm's default parallel-edge merging, so multiple edges + between the same pair of nodes are each routed with distinct waypoints, while + defaulting to `true` so every pre-existing caller's behavior is unchanged. + justification: | + InterconnectionViewLayoutStrategy needs distinct parallel connectors preserved (see its + own ParallelConnectorPreservation requirement), while ActionFlowViewLayoutStrategy and + StateTransitionViewLayoutStrategy must keep their existing default-merge behavior + unchanged. An additive, default-`true` parameter lets one adapter method serve both + needs without altering any existing call site. + tests: + - InterconnectionView_BuildLayout_TwoConnectionsSamePair_ProducesTwoConnectorsWithoutException + - InterconnectionView_BuildLayout_ThreeParallelConnections_ProducesThreeDistinctConnectors + + - id: SysML2Tools-Core-Layout-Internal-LayeredPlacement-NamedPorts + title: >- + LayeredPlacement shall support placing edges through a named port on either endpoint via + a second `PlaceWithPorts` entry point, returning each requested port as a correctly + correlated placed port carrying the requested label, with parallel-edge merging always + disabled. + justification: | + InterconnectionViewLayoutStrategy needs the layout engine itself to resolve each part's + port sides, spacing, and any resulting box growth from named, labeled connection points, + rather than computing box heights and port positions by hand. A second, additive entry + point that models ports as first-class `LayoutGraphPort`s on the input graph — and + correlates the engine's placed ports back to the caller's requested endpoints by + reference identity — lets this strategy adopt the companion package's port model without + altering `Place`'s existing, unconditional behavior for its own callers. A strategy + rendering non-overlapping boxes with a correctly labeled port for every incident + connection, even under a high connection count, is the observable evidence. + tests: + - InterconnectionView_BuildLayout_ConnectionEndpointWithPortSegment_PortLabelReflectsSysmlPortName + - InterconnectionView_BuildLayout_ThreeParallelConnections_ProducesThreeDistinctConnectors + - InterconnectionView_BuildLayout_HighConnectionDegreePart_BoxesDoNotOverlapAndPortsAreLabeled + + - id: SysML2Tools-Core-Layout-Internal-LayeredPlacement-TitleAwarePorts + title: >- + PlaceWithPorts shall mark each node's title presence (HasLabel/HasKeyword) so the + layout engine reserves a title band on any node carrying named ports, keeping port + anchors and labels clear of the box's own title row. + justification: | + The layered algorithm's automatic title-vs-side-port reservation is keyed solely on + whether a node's Label/Keyword are non-null; PlaceWithPorts previously left both null + for every node, so ports were freely placed across a box's own title band, colliding + with the "«keyword» / name : type" header text the renderer draws there. Flagging each + node's HasLabel/HasKeyword and setting a non-null sentinel on the created + LayoutGraphNode activates that reservation with no other observable effect (the + sentinel carries no SysML semantics, matching this unit's documented no-semantics + contract), so no left/right port ever overlaps the box's own title area is the + observable evidence. + tests: + - InterconnectionView_BuildLayout_PartWithPorts_PortsNeverOverlapBoxTitleArea + - InterconnectionView_BuildLayout_HighConnectionDegreePart_BoxesDoNotOverlapAndPortsAreLabeled diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index ab1fe39..066dbdf 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -158,6 +158,20 @@ Named `view Name { ... }` usages (not just `view def` declarations) are also now their own renderable declarations: a workspace containing both `view def` declarations and named `view` usages surfaces both kinds as views the `render` command discovers and renders. +### Interconnection View Connector Detail + +An Interconnection View's connector endpoints now show the SysML port name from the connection's +endpoint reference (for example a connection between `StepperMotorX.encoder` and +`LBO3AxisGantry.J40` labels its two ports `encoder` and `J40`), instead of leaving every port +unlabeled. When several distinct connections wire the same two parts (for example separate +`power`, `encoder`, and `sensor` connections between one controller and one motor), each +connection now renders as its own independently-routed connector line, rather than visually +collapsing onto a single shared line. A connection whose endpoint reaches into a part nested +inside a container (for example `connect psu to board.cpu`) shows the port label for the true +nested target (`cpu`), but the connector line itself still terminates at the containing `board` +box's own boundary rather than continuing on to the inner part — routing a connector all the way +into a nested container remains a known limitation. + ## Expose vs. Render: Worked Examples The three view body statements look similar but do very different jobs. This is a common point 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 index a7d8520..96440e4 100644 --- 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 @@ -5,11 +5,27 @@ `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. Nested-layout -tests build a two-level workspace (a part typed by a definition with its own internal parts) and -assert on the container box's nested `Children`. No mocking is required; the strategy depends only -on the in-memory model, `LayeredPlacement`, and render options. +returned `LayoutTree`. Because the root container box nests all interior content as its own +`Children`, `LayoutTree.Nodes`/`.Children` are read through recursive `CollectBoxes`/`CollectPorts`/ +`CollectLines` helpers (mirroring `GeneralViewLayoutStrategyTests`'s established pattern) that walk +every nested `LayoutBox.Children` rather than reading `.Nodes.OfType()` directly. Assertions +count the container box, rounded part boxes, port nodes, and connector lines, and a geometric +helper confirms that no two part boxes overlap. Nested-layout tests build a two-level workspace (a +part typed by a definition with its own internal parts) and assert on the container box's nested +`Children`. Parallel-connection tests assert that multiple connections between the same two parts +produce pairwise-distinct routed waypoints (not a shared route), and port-labeling tests assert +`LayoutPort.ExternalLabel` reflects the real SysML port-name segment from a dotted endpoint +reference, including the cross-boundary (label-only) case. A dedicated test asserts the root-box +nesting invariant directly (`LayoutTree.Nodes` has exactly one element, and that element's +`Children` hold the interior content), and another exercises a high-connection-degree part to +confirm boxes remain non-overlapping and every incident connection still yields a labeled port, +now that box sizing/port spacing is fully delegated to the layered engine (via +`LayeredPlacement.PlaceWithPorts`) instead of the removed `MinPortSlot`/`ConnectorClearance` +heuristic. A further test confirms every left/right port sits below its own box's title area +(`BoxMetrics.TitleAreaHeight`) — the label-collision defect fixed by flagging every part node +`HasLabel: true, HasKeyword: true` so the layered algorithm's automatic title-vs-side-port +reservation activates for it. No mocking is required; the strategy depends only on the in-memory +model, `LayeredPlacement`, and render options. ##### Test Environment @@ -22,6 +38,15 @@ configuration are required beyond a standard .NET SDK installation. - 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. +- Two distinct connections between the same two parts render as two independently-routed + connectors with genuinely distinct waypoints (parallel-edge preservation), not one shared route. +- Three distinct connections between the same two parts (mirroring a real 3-axis-gantry wiring + model) render as three pairwise-distinct connectors, each with its own labeled port pair. +- A connection endpoint with a dotted port segment (e.g. `StepperMotorX.encoder`) produces a + `LayoutPort` whose `ExternalLabel` is the real SysML port name, not `null`. +- A connection endpoint referencing a nested/cross-boundary path (e.g. `board.cpu`) still + terminates its connector at the containing part's own boundary (the documented remaining + limitation), but its port label reflects the true nested target name. - An empty workspace yields a canvas with no nodes. - A part typed by a definition with its own internal parts is rendered as a container box whose nested children lie inside its bounds, below its title area. @@ -50,12 +75,29 @@ configuration are required beyond a standard .NET SDK installation. edges, the connections/parts score heuristic breaks the tie, selecting the candidate with the better score even when its qualified name is shorter — proving the tie-break is depth-based, not a raw qualified-name-length comparison. +- The root container box nests its interior content (part boxes, ports, and connector lines) as its + own `Children` rather than as flat top-level siblings: `LayoutTree.Nodes` contains exactly one + element (the root box). +- A part with a high connection degree still produces non-overlapping boxes and a labeled port for + every incident connection, now that box sizing and port spacing are fully delegated to the + layered engine instead of the removed `MinPortSlot`/`ConnectorClearance` heuristic. +- No left/right port's centre falls within its owning part box's own title area, even under a high + connection count — the layered algorithm's automatic title-vs-side-port reservation, activated by + flagging every part node `HasLabel: true, HasKeyword: true`, keeps ports clear of the box's own + "«keyword» / name : type" header row. ##### Test Scenarios | Test | Assertion | | --- | --- | | `InterconnectionView_BuildLayout_PartsAndConnections_ProducesBoxesPortsAndLines` | Box, parts, ports, and lines | +| `InterconnectionView_BuildLayout_RootContent_IsNestedAsRootBoxChildren` | Root box nesting invariant | +| `InterconnectionView_BuildLayout_TwoConnectionsSamePair_ProducesTwoConnectorsWithoutException` | Distinct waypoints | +| `InterconnectionView_BuildLayout_ThreeParallelConnections_ProducesThreeDistinctConnectors` | Six labeled ports | +| `InterconnectionView_BuildLayout_HighConnectionDegreePart_BoxesDoNotOverlapAndPortsAreLabeled` | No overlap | +| `InterconnectionView_BuildLayout_PartWithPorts_PortsNeverOverlapBoxTitleArea` | Ports clear of title area | +| `InterconnectionView_BuildLayout_ConnectionEndpointWithPortSegment_PortLabelReflectsSysmlPortName` | Port label | +| `InterconnectionView_BuildLayout_CrossBoundaryEndpoint_LabelReflectsNestedTarget` | Nested label, boundary connector | | `InterconnectionView_BuildLayout_PartBoxes_DoNotOverlap` | No two rounded part boxes overlap | | `InterconnectionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas` | Canvas with no nodes | | `InterconnectionView_BuildLayout_NestedContainer_PlacesChildrenInsideContainerBox` | Children nested inside the box | diff --git a/docs/verification/sysml2-tools-core/layout/internal/layered-placement.md b/docs/verification/sysml2-tools-core/layout/internal/layered-placement.md index d20dadb..ba90075 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/layered-placement.md +++ b/docs/verification/sysml2-tools-core/layout/internal/layered-placement.md @@ -6,10 +6,13 @@ layered algorithm and has no direct unit test of its own. It is verified indirectly through the view layout strategy tests that depend on it: `ActionFlowViewLayoutStrategyTests`, `StateTransitionViewLayoutStrategyTests`, `InterconnectionViewLayoutStrategyTests`, and -`GeneralViewLayoutStrategyTests`. Those strategies pass sized nodes and directed edges to +`GeneralViewLayoutStrategyTests`. `ActionFlowViewLayoutStrategyTests` and +`StateTransitionViewLayoutStrategyTests` pass sized nodes and directed edges to `LayeredPlacement.Place` and build their `LayoutTree` from the returned rectangles and polylines, so a passing strategy layout is evidence that the adapter placed nodes and routed edges correctly. -No mocking is used; the real layered algorithm runs on every test. +`InterconnectionViewLayoutStrategyTests` instead exercises `LayeredPlacement.PlaceWithPorts`, whose +returned placed rectangles, polylines, and correlated placed ports feed that strategy's `LayoutTree` +in the same way. No mocking is used; the real layered algorithm runs on every test. ##### Test Environment @@ -25,6 +28,25 @@ configuration are required beyond a standard .NET SDK installation and the refer - Routing returns one polyline per input edge in input-edge order, oriented source-to-target even when the input contains cycles. - The requested flow direction is honored so a forward chain reads top-to-bottom. +- The additive `mergeParallelEdges` parameter on `Place` (default `true`, unchanged for + `ActionFlowViewLayoutStrategy`/`StateTransitionViewLayoutStrategy`) is exercised transitively via + `InterconnectionViewLayoutStrategyTests`, whose parallel-connection tests assert distinct routed + waypoints per parallel edge — `PlaceWithPorts` (which that strategy calls) unconditionally + disables parallel-edge merging, so the same distinct-waypoints guarantee holds there too. No + dedicated `LayeredPlacementTests` file exists — consistent with the established pattern of + covering this adapter only through the strategies that call it. +- `PlaceWithPorts` returns exactly one correctly correlated placed port per requested port endpoint + (`null` only when no port was requested for that endpoint), each carrying the requested + `EdgePortRef.Label` as its `ExternalLabel`, verified transitively via + `InterconnectionViewLayoutStrategyTests`'s port-labeling tests + (`InterconnectionView_BuildLayout_ConnectionEndpointWithPortSegment_PortLabelReflectsSysmlPortName`, + `InterconnectionView_BuildLayout_ThreeParallelConnections_ProducesThreeDistinctConnectors`, and + `InterconnectionView_BuildLayout_HighConnectionDegreePart_BoxesDoNotOverlapAndPortsAreLabeled`). +- `PlaceWithPorts` marks each input node's title presence (`HasLabel`/`HasKeyword`) on the created + `LayoutGraphNode`, so the layered algorithm's automatic title-vs-side-port reservation activates + for any node carrying named ports, keeping port anchors clear of the box's own title row — + verified transitively via `InterconnectionViewLayoutStrategyTests`'s + `InterconnectionView_BuildLayout_PartWithPorts_PortsNeverOverlapBoxTitleArea`. ##### Test Scenarios @@ -33,6 +55,10 @@ configuration are required beyond a standard .NET SDK installation and the refer | `ActionFlowView_BuildLayout_ActionsAndSuccessions_ProducesBoxesMarkersAndFlows` | Boxes and flows produced | | `StateTransitionView_BuildLayout_StatesAndTransitions_ProducesBoxesBadgeAndLines` | State boxes and lines produced | | `InterconnectionView_BuildLayout_PartsAndConnections_ProducesBoxesPortsAndLines` | Part boxes and lines produced | +| `InterconnectionView_BuildLayout_TwoConnectionsSamePair_ProducesTwoConnectorsWithoutException` | Distinct waypoints | +| `InterconnectionView_BuildLayout_ThreeParallelConnections_ProducesThreeDistinctConnectors` | Distinct routes | +| `InterconnectionView_BuildLayout_HighConnectionDegreePart_BoxesDoNotOverlapAndPortsAreLabeled` | Port correlation | +| `InterconnectionView_BuildLayout_PartWithPorts_PortsNeverOverlapBoxTitleArea` | Title area reservation | | `InterconnectionView_BuildLayout_PartBoxes_DoNotOverlap` | Placed rectangles do not overlap | | `ActionFlowView_BuildLayout_NoOverlap` | Placed action boxes do not overlap | | `ActionFlowView_BuildLayout_SuccessionEdge_IsDashedWithOpenArrowhead` | Polyline oriented to the target | diff --git a/src/DemaConsulting.SysML2Tools.Core/DemaConsulting.SysML2Tools.Core.csproj b/src/DemaConsulting.SysML2Tools.Core/DemaConsulting.SysML2Tools.Core.csproj index 8774997..6834048 100644 --- a/src/DemaConsulting.SysML2Tools.Core/DemaConsulting.SysML2Tools.Core.csproj +++ b/src/DemaConsulting.SysML2Tools.Core/DemaConsulting.SysML2Tools.Core.csproj @@ -56,7 +56,7 @@ - + diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index 2ef238a..bdf58c8 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -555,6 +555,15 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( int depthLimit) { var graph = new LayoutGraph(); + + // The General View intentionally draws one edge per source model relationship even when + // several distinct edges share the same source and target node (for example two attributes + // of the same type, or a redefinition edge that happens to coincide with another edge + // between the same two definitions) — unlike InterconnectionViewLayoutStrategy, this graph + // has never relied on the bundled algorithm's parallel-edge merging, so it opts out + // explicitly to keep every distinct model relationship visible regardless of the layered + // algorithm's own default. + graph.Set(CoreOptions.MergeParallelEdges, false); var truncated = new List(); // Reserve the full title area (package keyword + name) above a folder's contents so the diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs index 033e912..da34ee4 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs @@ -22,9 +22,13 @@ namespace DemaConsulting.SysML2Tools.Layout.Internal; /// box for the host definition. /// /// -/// Box heights are scaled to ensure each port has at least px of -/// vertical clearance, so connectors remain visually distinct regardless of connection count. -/// All placement and routing is delegated to the layered algorithm via . +/// Each nested part's ports are modeled as named LayoutGraphPorts on the layered +/// algorithm's input graph (via ), so the engine +/// itself resolves port sides, spacing, and any resulting box-height growth needed to keep +/// connectors visually distinct regardless of connection count — the strategy no longer computes +/// box heights or port positions by hand. Every part node is also flagged as carrying a title +/// (HasLabel/HasKeyword), so the engine's automatic title-vs-side-port reservation +/// keeps ports clear of each box's own title band instead of only growing the box height. /// /// /// When a nested part is itself typed by a part def that has its own internal parts, the @@ -45,12 +49,6 @@ internal sealed class InterconnectionViewLayoutStrategy : ILayoutStrategy /// Approximate width-per-character factor relative to the title font size. private const double CharWidthFactor = 0.62; - /// Minimum vertical slot per port on a box face, for height-scaling. - private const double MinPortSlot = 11.0; - - /// Clearance used when computing the minimum box height from port count. - private const double ConnectorClearance = 10.0; - /// /// Uniform padding the layered algorithm adds around placed content (mirrors its internal /// content padding), used to give routed connectors the same trailing inset as the boxes. @@ -71,8 +69,13 @@ private sealed record PartItem( double Height, IReadOnlyList? InnerContent); - /// A resolved binary connection between two nested-part indices. - private sealed record ConnPair(int A, int B); + /// + /// A resolved binary connection between two nested-part indices, together with the port-name + /// label for each end (the dotted-reference remainder after the resolved part, e.g. + /// "encoder" for StepperMotorX.encoder), or when the + /// endpoint reference names the part directly with no port segment. + /// + private sealed record ConnPair(int A, int B, string? LabelA, string? LabelB); /// The laid-out interior of one definition: its full container size and content. /// Full container width including title area and insets. @@ -116,25 +119,23 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var interior = LayOutInterior(root, theme, depth: 0, defsByName, visited, scope); - var nodes = new List(interior.Content.Count + 1) - { - // Container box for the root part definition. - new LayoutBox( - X: 0, - Y: 0, - Width: interior.Width, - Height: interior.Height, - Label: root.Name ?? "Interconnection", - Depth: 0, - Shape: BoxShape.Rectangle, - Compartments: [], - Children: [], - Keyword: string.IsNullOrEmpty(root.DefinitionKeyword) ? "part def" : root.DefinitionKeyword), - }; - - nodes.AddRange(interior.Content); - - return new LayoutTree(interior.Width, interior.Height, nodes); + // Container box for the root part definition. The root sits at the same origin (0, 0) + // that interior.Content is already positioned relative to, so the interior content is + // nested directly as this box's Children (mirroring MakePartBox's own nesting pattern) + // with no translation needed. + var rootBox = new LayoutBox( + X: 0, + Y: 0, + Width: interior.Width, + Height: interior.Height, + Label: root.Name ?? "Interconnection", + Depth: 0, + Shape: BoxShape.Rectangle, + Compartments: [], + Children: interior.Content, + Keyword: string.IsNullOrEmpty(root.DefinitionKeyword) ? "part def" : root.DefinitionKeyword); + + return new LayoutTree(interior.Width, interior.Height, [rootBox]); } /// @@ -164,26 +165,27 @@ private static InteriorLayout LayOutInterior( var partIndex = BuildPartIndex(parts); var pairs = ResolveConnections(def, partIndex); - // Scale each box height to guarantee at least MinPortSlot px per port on its face. - var degree = new int[parts.Count]; - foreach (var p in pairs) - { - degree[p.A]++; - degree[p.B]++; - } + var nodeSizes = parts.Select(p => (p.Width, p.Height, HasLabel: true, HasKeyword: true)).ToList(); - var nodeSizes = parts - .Select((p, i) => - { - var minH = (degree[i] * MinPortSlot) + (2.0 * ConnectorClearance); - return (p.Width, Math.Max(p.Height, minH)); - }) + var portEdges = pairs + .Select(p => new PortEdge( + p.A, + p.B, + new EdgePortRef(p.LabelA), + new EdgePortRef(p.LabelB))) .ToList(); - var edgePairs = pairs.Select(p => (p.A, p.B)).ToList(); - - // Delegate all placement and routing to the layered algorithm. - var placed = LayeredPlacement.Place(nodeSizes, edgePairs, LayoutFlowDirection.Right); + // Delegate all placement and routing to the layered algorithm. Ports are modeled as named + // LayoutGraphPorts on each connection's endpoints, so the engine resolves port sides, + // spacing, and any resulting box-height growth needed to keep connectors visually distinct + // regardless of connection count. Every part is flagged HasLabel/HasKeyword (matching the + // hasLabel/hasKeyword: true convention already used for BoxMetrics.TitleAreaHeight below and + // in ComputePartSize), which activates the engine's automatic title-vs-side-port reservation + // so ports never land within the box's own title band. Parallel-edge merging is disabled so + // distinct SysML connections between the same two parts (e.g. a "power" and an "encoder" + // connection both wired between the same two nested parts) are each preserved as their own + // independently-routed connector instead of collapsing onto one shared route. + var placed = LayeredPlacement.PlaceWithPorts(nodeSizes, portEdges, LayoutFlowDirection.Right); // Shift placed content down/right to sit inside the container box. var titleArea = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true); @@ -230,11 +232,28 @@ private static InteriorLayout LayOutInterior( // Shift all waypoints by the container offset. var shifted = wp.Select(p => new Point2D(p.X + offsetX, p.Y + offsetY)).ToList(); - // Source port: first waypoint on the source box's right face. - content.Add(new LayoutPort(shifted[0].X, shifted[0].Y, PortSide.Right, null)); + var edgePorts = placed.EdgePorts[k]; + + // Source/target ports: engine-placed ports on the source/target boxes' resolved faces, + // labeled with the SysML port-name segment from the connection's endpoint reference, if + // any, and translated by the same container offset as the boxes and waypoints. + if (edgePorts.Source is { } sourcePort) + { + content.Add(sourcePort with + { + CentreX = sourcePort.CentreX + offsetX, + CentreY = sourcePort.CentreY + offsetY, + }); + } - // Target port: last waypoint on the target box's left face. - content.Add(new LayoutPort(shifted[^1].X, shifted[^1].Y, PortSide.Left, null)); + if (edgePorts.Target is { } targetPort) + { + content.Add(targetPort with + { + CentreX = targetPort.CentreX + offsetX, + CentreY = targetPort.CentreY + offsetY, + }); + } content.Add(new LayoutLine( Waypoints: shifted, @@ -444,7 +463,8 @@ private static Dictionary BuildPartIndex(IReadOnlyList pa /// /// Resolves each binary connection's endpoints to nested-part indices by matching the - /// first segment of the dotted endpoint reference against the part names. + /// first segment of the dotted endpoint reference against the part names, capturing any + /// remaining dotted segment(s) as the port-name label for that end. /// private static IReadOnlyList ResolveConnections( SysmlDefinitionNode root, @@ -453,28 +473,41 @@ private static IReadOnlyList ResolveConnections( var pairs = new List(); foreach (var conn in root.Children.OfType()) { - var a = ResolveEndpoint(conn.EndpointA, partIndex); - var b = ResolveEndpoint(conn.EndpointB, partIndex); + var (a, labelA) = ResolveEndpoint(conn.EndpointA, partIndex); + var (b, labelB) = ResolveEndpoint(conn.EndpointB, partIndex); if (a >= 0 && b >= 0 && a != b) { - pairs.Add(new ConnPair(a, b)); + pairs.Add(new ConnPair(a, b, labelA, labelB)); } } return pairs; } - /// Resolves a dotted endpoint reference to a part index via its first segment. - private static int ResolveEndpoint(string? reference, Dictionary partIndex) + /// + /// Resolves a dotted endpoint reference (e.g. "StepperMotorX.encoder") to a part index by + /// its first segment, and returns the remaining dotted segment(s) — the SysML port-name portion + /// of the reference — as the port label. A reference with no further segments (a bare part name) + /// resolves with a label. + /// + /// + /// This resolves the full dotted path for labeling purposes, so a deeper reference such + /// as "board.cpu" (into a nested part inside a container) yields the label "cpu" + /// rather than discarding it. The connector itself still terminates at the container box's own + /// port — see the "Cross-boundary limitation" note in the design documentation for what one-level + /// cross-boundary routing this strategy does and does not perform. + /// + private static (int Index, string? Label) ResolveEndpoint(string? reference, Dictionary partIndex) { if (string.IsNullOrEmpty(reference)) { - return -1; + return (-1, null); } var dot = reference.IndexOf('.', StringComparison.Ordinal); var head = dot >= 0 ? reference[..dot] : reference; - return partIndex.TryGetValue(head, out var i) ? i : -1; + var label = dot >= 0 ? reference[(dot + 1)..] : null; + return partIndex.TryGetValue(head, out var i) ? (i, label) : (-1, null); } /// Computes the intrinsic size of a nested part box. diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayeredPlacement.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayeredPlacement.cs index 28010c9..c4def3e 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayeredPlacement.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayeredPlacement.cs @@ -25,6 +25,51 @@ internal sealed record PlacedLayout( double Width, double Height); +/// +/// Result of a port-aware layered placement (see ). +/// +/// Placed box rectangles, one per input node in input-node order. +/// +/// Routed orthogonal connector polylines, one per input edge in input-edge order. Each polyline is +/// already oriented source-to-target (the algorithm reverses back edges internally). +/// +/// +/// The placed source/target ports, one pair per input edge in input-edge order. An element is +/// when the corresponding or +/// was itself (no port requested for that +/// endpoint; the edge attaches to the plain node instead). +/// +/// Overall content width in logical pixels. +/// Overall content height in logical pixels. +internal sealed record PlacedPortLayout( + IReadOnlyList Rects, + IReadOnlyList> EdgePolylines, + IReadOnlyList<(LayoutPort? Source, LayoutPort? Target)> EdgePorts, + double Width, + double Height); + +/// +/// Requests that an edge endpoint attach through a named port on its node rather than to the node +/// as a whole, so a caller can label the specific connection point on the node's boundary. +/// +/// +/// Optional external label rendered beside the port (see +/// ). when no label should be +/// shown; a port is still created and placed in that case. +/// +internal sealed record EdgePortRef(string? Label); + +/// +/// A directed edge between two nodes (by index), with an optional named-port request at either +/// endpoint. A or means +/// that endpoint attaches directly to the node itself rather than through a named port. +/// +/// Index of the source node. +/// Index of the target node. +/// Optional named-port request at the source endpoint. +/// Optional named-port request at the target endpoint. +internal sealed record PortEdge(int From, int To, EdgePortRef? SourcePort, EdgePortRef? TargetPort); + /// /// Adapts the DemaConsulting.Rendering layout engine to the SysML view strategies for flat, /// flow-chart-like diagrams. It builds a from sized nodes and directed @@ -51,6 +96,16 @@ internal static class LayeredPlacement /// Sized nodes to place, in caller order. /// Directed edges between nodes (by index), in caller order. /// Primary flow direction for the layered layout. + /// + /// Whether multiple edges between the same pair of nodes are merged into a single routed + /// connector. Defaults to (the library's own default and this method's + /// original, unconditional behavior), which keeps every pre-existing call site — including + /// ActionFlowViewLayoutStrategy and StateTransitionViewLayoutStrategy — byte-for-byte + /// unchanged. Pass to have every parallel edge preserved as its own + /// independently-routed connector (see ), which + /// InterconnectionViewLayoutStrategy requests so distinct SysML connections between the same + /// two parts never collapse onto one shared route. + /// /// The placed rectangles, routed polylines, and overall content size. /// /// Thrown when or is . @@ -58,7 +113,8 @@ internal static class LayeredPlacement public static PlacedLayout Place( IReadOnlyList<(double Width, double Height)> nodes, IReadOnlyList<(int From, int To)> edges, - LayoutFlowDirection direction) + LayoutFlowDirection direction, + bool mergeParallelEdges = true) { ArgumentNullException.ThrowIfNull(nodes); ArgumentNullException.ThrowIfNull(edges); @@ -78,6 +134,10 @@ public static PlacedLayout Place( } graph.Set(CoreOptions.Direction, direction); + if (!mergeParallelEdges) + { + graph.Set(CoreOptions.MergeParallelEdges, false); + } var tree = LayoutEngine.Layout(graph); @@ -98,4 +158,141 @@ public static PlacedLayout Place( return new PlacedLayout(rects, polylines, tree.Width, tree.Height); } + + /// + /// Places sized nodes and directed edges with , + /// attaching each edge endpoint through a named when requested so + /// the engine itself resolves port sides, spacing, and any box-height growth needed to keep + /// distinct connections visually distinct. This is a second, additive entry point: + /// itself is unmodified and remains the entry point for callers that attach + /// edges directly to nodes. + /// + /// + /// Sized nodes to place, in caller order. HasLabel/HasKeyword indicate whether the + /// node carries a title (a name and/or keyword) that will be rendered on the box; when either is + /// , the created 's corresponding + /// / is set to a non-null + /// sentinel () so the layered algorithm's automatic title-vs-side-port + /// reservation activates for that node (it keys only on whether Label/Keyword are + /// non-null, not their text) and no named port is ever placed across the box's own title band. + /// + /// Directed edges between nodes (by index), with optional named-port requests at either endpoint, in caller order. + /// Primary flow direction for the layered layout. + /// + /// The placed rectangles, routed polylines, correlated placed ports (one pair per input edge), + /// and overall content size. + /// + /// + /// Thrown when or is . + /// + /// + /// Every edge's endpoint ports are given a unique name within their owning node, + /// "{edgeIndex}-a" for the source and "{edgeIndex}-b" for the target, so multiple + /// connections into the same node never collide. is + /// unconditionally set to (unlike 's optional + /// parameter), since every current caller of this method needs parallel-edge preservation. + /// + public static PlacedPortLayout PlaceWithPorts( + IReadOnlyList<(double Width, double Height, bool HasLabel, bool HasKeyword)> nodes, + IReadOnlyList edges, + LayoutFlowDirection direction) + { + ArgumentNullException.ThrowIfNull(nodes); + ArgumentNullException.ThrowIfNull(edges); + + var graph = new LayoutGraph(); + var graphNodes = new LayoutGraphNode[nodes.Count]; + for (var i = 0; i < nodes.Count; i++) + { + graphNodes[i] = graph.AddNode( + i.ToString(CultureInfo.InvariantCulture), nodes[i].Width, nodes[i].Height); + + // Give the node a non-null Label/Keyword sentinel (not the real title text, which + // LayeredPlacement never carries) purely so the layered algorithm's automatic + // title-vs-side-port reservation (keyed on whether Label/Keyword are non-null) activates + // for nodes that will actually render a title, keeping ports clear of the title band. + if (nodes[i].HasLabel) + { + graphNodes[i].Label = string.Empty; + } + + if (nodes[i].HasKeyword) + { + graphNodes[i].Keyword = string.Empty; + } + } + + // For each edge, resolve the source/target ILayoutConnectable endpoints, creating a named + // port on the owning node when the caller requested one, and remembering the created + // LayoutGraphPort (if any) so the placed LayoutPort can be correlated back to it below. + var sourcePorts = new LayoutGraphPort?[edges.Count]; + var targetPorts = new LayoutGraphPort?[edges.Count]; + for (var e = 0; e < edges.Count; e++) + { + var edge = edges[e]; + + ILayoutConnectable source = graphNodes[edge.From]; + if (edge.SourcePort is { } sourceRef) + { + var port = graphNodes[edge.From].Ports.AddPort( + e.ToString(CultureInfo.InvariantCulture) + "-a"); + port.ExternalLabel = sourceRef.Label; + sourcePorts[e] = port; + source = port; + } + + ILayoutConnectable target = graphNodes[edge.To]; + if (edge.TargetPort is { } targetRef) + { + var port = graphNodes[edge.To].Ports.AddPort( + e.ToString(CultureInfo.InvariantCulture) + "-b"); + port.ExternalLabel = targetRef.Label; + targetPorts[e] = port; + target = port; + } + + graph.AddEdge(e.ToString(CultureInfo.InvariantCulture), source, target); + } + + graph.Set(CoreOptions.Direction, direction); + graph.Set(CoreOptions.MergeParallelEdges, false); + + var tree = LayoutEngine.Layout(graph); + + var boxes = tree.Nodes.OfType().ToList(); + var lines = tree.Nodes.OfType().ToList(); + var ports = tree.Nodes.OfType().ToList(); + + var rects = new Rect[boxes.Count]; + for (var i = 0; i < boxes.Count; i++) + { + rects[i] = new Rect(boxes[i].X, boxes[i].Y, boxes[i].Width, boxes[i].Height); + } + + var polylines = new IReadOnlyList[lines.Count]; + for (var i = 0; i < lines.Count; i++) + { + polylines[i] = lines[i].Waypoints; + } + + var edgePorts = new (LayoutPort? Source, LayoutPort? Target)[edges.Count]; + for (var e = 0; e < edges.Count; e++) + { + LayoutPort? source = null; + LayoutPort? target = null; + if (sourcePorts[e] is { } wantedSource) + { + source = ports.Find(p => ReferenceEquals(p.SourcePort, wantedSource)); + } + + if (targetPorts[e] is { } wantedTarget) + { + target = ports.Find(p => ReferenceEquals(p.SourcePort, wantedTarget)); + } + + edgePorts[e] = (source, target); + } + + return new PlacedPortLayout(rects, polylines, edgePorts, tree.Width, tree.Height); + } } diff --git a/src/DemaConsulting.SysML2Tools.Tool/DemaConsulting.SysML2Tools.Tool.csproj b/src/DemaConsulting.SysML2Tools.Tool/DemaConsulting.SysML2Tools.Tool.csproj index fe17285..e34166f 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/DemaConsulting.SysML2Tools.Tool.csproj +++ b/src/DemaConsulting.SysML2Tools.Tool/DemaConsulting.SysML2Tools.Tool.csproj @@ -50,9 +50,9 @@ - - - + + + @@ -90,8 +90,8 @@ - - + + diff --git a/test/DemaConsulting.SysML2Tools.Tests/DemaConsulting.SysML2Tools.Tests.csproj b/test/DemaConsulting.SysML2Tools.Tests/DemaConsulting.SysML2Tools.Tests.csproj index 89bb352..9ccc32a 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/DemaConsulting.SysML2Tools.Tests.csproj +++ b/test/DemaConsulting.SysML2Tools.Tests/DemaConsulting.SysML2Tools.Tests.csproj @@ -56,15 +56,15 @@ - - + + - - - + + + diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs index cad4e28..a864485 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs @@ -48,19 +48,185 @@ public void InterconnectionView_BuildLayout_PartsAndConnections_ProducesBoxesPor 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(); + var boxes = CollectBoxes(layout.Nodes).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()); + Assert.Equal(2, CollectPorts(layout.Nodes).Count()); + Assert.Single(CollectLines(layout.Nodes)); + } + + /// + /// The root container box nests its interior content (part boxes, ports, and connector + /// lines) as its own rather than as flat top-level + /// siblings: contains exactly one element (the root box), and + /// that box's Children contains the expected part boxes, ports, and connector line. + /// + [Fact] + public void InterconnectionView_BuildLayout_RootContent_IsNestedAsRootBoxChildren() + { + // Arrange: a simple 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: exactly one top-level node (the root container box). + var root = Assert.Single(layout.Nodes); + var rootBox = Assert.IsType(root); + Assert.Equal("part def", rootBox.Keyword); + Assert.Equal("PowerSystem", rootBox.Label); + + // Assert: the root box's own Children hold the interior content — two part boxes, two + // ports, and one connector line — none of it as flat top-level siblings. + Assert.NotEmpty(rootBox.Children); + Assert.Equal(2, rootBox.Children.OfType().Count(b => b.Shape == BoxShape.RoundedRectangle)); + Assert.Equal(2, rootBox.Children.OfType().Count()); + Assert.Single(rootBox.Children.OfType()); + } + + /// + /// A part with a high connection degree (many incident connections) still produces + /// non-overlapping boxes and a labeled port for every incident connection, now that box + /// sizing/port spacing is fully delegated to the layered engine instead of the removed + /// MinPortSlot/ConnectorClearance heuristic. + /// + [Fact] + public void InterconnectionView_BuildLayout_HighConnectionDegreePart_BoxesDoNotOverlapAndPortsAreLabeled() + { + // Arrange: a Gantry part def with a controller wired to a motor by five separate connections + // (a higher connection degree than the ThreeParallelConnections test), exercising the + // engine's own port-spacing/box-height resolution. + var strategy = new InterconnectionViewLayoutStrategy(); + var gantry = new SysmlDefinitionNode + { + Name = "Gantry", + QualifiedName = "M::Gantry", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "controller", QualifiedName = "M::Gantry::controller", FeatureKeyword = "part", FeatureTyping = "Controller" }, + new SysmlFeatureNode { Name = "motor", QualifiedName = "M::Gantry::motor", FeatureKeyword = "part", FeatureTyping = "Motor" }, + new SysmlConnectionNode { Name = "power", QualifiedName = "M::Gantry::power", ConnectionKeyword = "connection", EndpointA = "controller.power", EndpointB = "motor.power" }, + new SysmlConnectionNode { Name = "encoder", QualifiedName = "M::Gantry::encoder", ConnectionKeyword = "connection", EndpointA = "controller.J40", EndpointB = "motor.encoder" }, + new SysmlConnectionNode { Name = "sensor", QualifiedName = "M::Gantry::sensor", ConnectionKeyword = "connection", EndpointA = "controller.sensor", EndpointB = "motor.SensorPort" }, + new SysmlConnectionNode { Name = "opto", QualifiedName = "M::Gantry::opto", ConnectionKeyword = "connection", EndpointA = "controller.opto", EndpointB = "motor.OptoSensor" }, + new SysmlConnectionNode { Name = "limit", QualifiedName = "M::Gantry::limit", ConnectionKeyword = "connection", EndpointA = "controller.limit", EndpointB = "motor.LimitSwitch" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["M::Gantry"] = gantry } + }; + var context = new ViewContext("GantryInterconnectionView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: the two part boxes never overlap, regardless of how many ports the engine placed + // on either of them. + var partBoxes = CollectBoxes(layout.Nodes).Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); + Assert.Equal(2, partBoxes.Count); + Assert.False(Overlaps(partBoxes[0], partBoxes[1])); + + // Assert: every incident connection produced a labeled port (ten ports total: five + // connections, two endpoints each). + var ports = CollectPorts(layout.Nodes).ToList(); + Assert.Equal(10, ports.Count); + Assert.Contains(ports, p => p.ExternalLabel == "power"); + Assert.Contains(ports, p => p.ExternalLabel == "J40"); + Assert.Contains(ports, p => p.ExternalLabel == "encoder"); + Assert.Contains(ports, p => p.ExternalLabel == "sensor"); + Assert.Contains(ports, p => p.ExternalLabel == "SensorPort"); + Assert.Contains(ports, p => p.ExternalLabel == "opto"); + Assert.Contains(ports, p => p.ExternalLabel == "OptoSensor"); + Assert.Contains(ports, p => p.ExternalLabel == "limit"); + Assert.Contains(ports, p => p.ExternalLabel == "LimitSwitch"); + } + + /// + /// No left/right port's centre falls within its owning part box's own title area. This + /// guards the label-collision defect fixed by flagging every part node as carrying a title + /// (HasLabel/HasKeyword) when handed to the layered algorithm (see + /// ), which activates the engine's automatic + /// title-vs-side-port reservation so ports never land in the header row a titled box renders. + /// + [Fact] + public void InterconnectionView_BuildLayout_PartWithPorts_PortsNeverOverlapBoxTitleArea() + { + // Arrange: reuse the high-connection-degree Gantry fixture, which already exercises many + // ports on one titled box. + var strategy = new InterconnectionViewLayoutStrategy(); + var gantry = new SysmlDefinitionNode + { + Name = "Gantry", + QualifiedName = "M::Gantry", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "controller", QualifiedName = "M::Gantry::controller", FeatureKeyword = "part", FeatureTyping = "Controller" }, + new SysmlFeatureNode { Name = "motor", QualifiedName = "M::Gantry::motor", FeatureKeyword = "part", FeatureTyping = "Motor" }, + new SysmlConnectionNode { Name = "power", QualifiedName = "M::Gantry::power", ConnectionKeyword = "connection", EndpointA = "controller.power", EndpointB = "motor.power" }, + new SysmlConnectionNode { Name = "encoder", QualifiedName = "M::Gantry::encoder", ConnectionKeyword = "connection", EndpointA = "controller.J40", EndpointB = "motor.encoder" }, + new SysmlConnectionNode { Name = "sensor", QualifiedName = "M::Gantry::sensor", ConnectionKeyword = "connection", EndpointA = "controller.sensor", EndpointB = "motor.SensorPort" }, + new SysmlConnectionNode { Name = "opto", QualifiedName = "M::Gantry::opto", ConnectionKeyword = "connection", EndpointA = "controller.opto", EndpointB = "motor.OptoSensor" }, + new SysmlConnectionNode { Name = "limit", QualifiedName = "M::Gantry::limit", ConnectionKeyword = "connection", EndpointA = "controller.limit", EndpointB = "motor.LimitSwitch" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["M::Gantry"] = gantry } + }; + var context = new ViewContext("GantryInterconnectionView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: every left/right port's centre sits at or below its owning box's title area, never + // inside the header row where the box's "«keyword» / name : type" title is drawn. + var partBoxes = CollectBoxes(layout.Nodes).Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); + var titleArea = BoxMetrics.TitleAreaHeight(options.Theme, hasLabel: true, hasKeyword: true); + var sidePorts = CollectPorts(layout.Nodes).Where(p => p.Side is PortSide.Left or PortSide.Right).ToList(); + Assert.NotEmpty(sidePorts); + + foreach (var port in sidePorts) + { + var owningBox = partBoxes.FirstOrDefault(b => + port.CentreY >= b.Y - 0.01 && port.CentreY <= b.Y + b.Height + 0.01 && + (Math.Abs(port.CentreX - b.X) < 0.5 || Math.Abs(port.CentreX - (b.X + b.Width)) < 0.5)); + + Assert.NotNull(owningBox); + Assert.True( + port.CentreY >= owningBox!.Y + titleArea, + $"port at ({port.CentreX}, {port.CentreY}) overlaps the title area of its box (Y={owningBox.Y}, titleArea={titleArea})"); + } } /// /// Two connection usages between the SAME two parts (both EndpointA=a, EndpointB=b) form - /// an identical directed pair. The interconnection engine de-duplicates the pair so its routed - /// connector waypoints are not 1:1 with the connections; the strategy must resolve each - /// connection by its endpoints (not by input position) and lay out without throwing, emitting a - /// connector polyline for each of the two connections. + /// an identical directed pair. With parallel-edge merging disabled for interconnection views, + /// every distinct SysML connection is preserved as its own independently-routed connector: the + /// two connections must resolve to two connector polylines with genuinely distinct waypoints + /// (separate parallel lanes), not one shared route that happens to be emitted twice. /// [Fact] public void InterconnectionView_BuildLayout_TwoConnectionsSamePair_ProducesTwoConnectorsWithoutException() @@ -87,17 +253,180 @@ public void InterconnectionView_BuildLayout_TwoConnectionsSamePair_ProducesTwoCo var context = new ViewContext("BoardInterconnectionView", workspace); var options = new RenderOptions(Themes.Light); - // Act: laying out must not throw even though the two connections share one routed polyline. + // Act: laying out must not throw, and each parallel connection gets its own routed lane. var layout = strategy.BuildLayout(context, options); - // Assert: two connector polylines (one per connection), each with at least two waypoints. - var lines = layout.Nodes.OfType().ToList(); + // Assert: two connector polylines (one per connection), each with at least two waypoints, + // and the two polylines are NOT identical — each is routed as its own distinct parallel lane. + var lines = CollectLines(layout.Nodes).ToList(); Assert.Equal(2, lines.Count); Assert.All(lines, l => Assert.True(l.Waypoints.Count >= 2)); + Assert.NotEqual(lines[0].Waypoints, lines[1].Waypoints); // Assert: two part boxes and one port pair per connection (four ports total). - Assert.Equal(2, layout.Nodes.OfType().Count(b => b.Shape == BoxShape.RoundedRectangle)); - Assert.Equal(4, layout.Nodes.OfType().Count()); + Assert.Equal(2, CollectBoxes(layout.Nodes).Count(b => b.Shape == BoxShape.RoundedRectangle)); + Assert.Equal(4, CollectPorts(layout.Nodes).Count()); + } + + /// + /// Three distinct connections between the same two parts (mirroring the 3-axis-gantry + /// wiring model that revealed the collapsing bug) each render as their own independently + /// routed connector with pairwise-distinct waypoints, and each connector's ports carry the + /// real SysML port name from the endpoint reference. + /// + [Fact] + public void InterconnectionView_BuildLayout_ThreeParallelConnections_ProducesThreeDistinctConnectors() + { + // Arrange: a Gantry part def with a controller and a motor wired by three separate connections. + var strategy = new InterconnectionViewLayoutStrategy(); + var gantry = new SysmlDefinitionNode + { + Name = "Gantry", + QualifiedName = "M::Gantry", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "controller", QualifiedName = "M::Gantry::controller", FeatureKeyword = "part", FeatureTyping = "Controller" }, + new SysmlFeatureNode { Name = "motor", QualifiedName = "M::Gantry::motor", FeatureKeyword = "part", FeatureTyping = "Motor" }, + new SysmlConnectionNode { Name = "power", QualifiedName = "M::Gantry::power", ConnectionKeyword = "connection", EndpointA = "controller.power", EndpointB = "motor.power" }, + new SysmlConnectionNode { Name = "encoder", QualifiedName = "M::Gantry::encoder", ConnectionKeyword = "connection", EndpointA = "controller.J40", EndpointB = "motor.encoder" }, + new SysmlConnectionNode { Name = "sensor", QualifiedName = "M::Gantry::sensor", ConnectionKeyword = "connection", EndpointA = "controller.sensor", EndpointB = "motor.SensorPort" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["M::Gantry"] = gantry } + }; + var context = new ViewContext("GantryInterconnectionView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: three distinct connectors, pairwise-different waypoints, six ports total. + var lines = CollectLines(layout.Nodes).ToList(); + Assert.Equal(3, lines.Count); + Assert.NotEqual(lines[0].Waypoints, lines[1].Waypoints); + Assert.NotEqual(lines[1].Waypoints, lines[2].Waypoints); + Assert.NotEqual(lines[0].Waypoints, lines[2].Waypoints); + + var ports = CollectPorts(layout.Nodes).ToList(); + Assert.Equal(6, ports.Count); + Assert.Contains(ports, p => p.ExternalLabel == "power"); + Assert.Contains(ports, p => p.ExternalLabel == "J40"); + Assert.Contains(ports, p => p.ExternalLabel == "encoder"); + Assert.Contains(ports, p => p.ExternalLabel == "sensor"); + Assert.Contains(ports, p => p.ExternalLabel == "SensorPort"); + } + + /// + /// A connection endpoint referencing a dotted port segment (e.g. StepperMotorX.encoder) + /// produces a whose is the real + /// SysML port name, not (the pre-fix behavior). + /// + [Fact] + public void InterconnectionView_BuildLayout_ConnectionEndpointWithPortSegment_PortLabelReflectsSysmlPortName() + { + // Arrange + var strategy = new InterconnectionViewLayoutStrategy(); + var root = new SysmlDefinitionNode + { + Name = "LBO3AxisGantry", + QualifiedName = "M::LBO3AxisGantry", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "StepperMotorX", QualifiedName = "M::LBO3AxisGantry::StepperMotorX", FeatureKeyword = "part", FeatureTyping = "StepperMotor" }, + new SysmlFeatureNode { Name = "LBO3AxisGantry", QualifiedName = "M::LBO3AxisGantry::LBO3AxisGantry", FeatureKeyword = "part", FeatureTyping = "Controller" }, + new SysmlConnectionNode + { + Name = "encoderConn", + QualifiedName = "M::LBO3AxisGantry::encoderConn", + ConnectionKeyword = "connection", + EndpointA = "StepperMotorX.encoder", + EndpointB = "LBO3AxisGantry.J40" + } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["M::LBO3AxisGantry"] = root } + }; + var context = new ViewContext("GantryInterconnectionView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: both ends carry their real SysML port-name label. + var ports = CollectPorts(layout.Nodes).ToList(); + Assert.Equal(2, ports.Count); + Assert.Contains(ports, p => p.ExternalLabel == "encoder"); + Assert.Contains(ports, p => p.ExternalLabel == "J40"); + } + + /// + /// A connection endpoint referencing a nested/cross-boundary path (e.g. board.cpu, into + /// a part inside a container) still resolves the connector to the containing part's own + /// boundary (the documented cross-boundary limitation is not fully lifted by this feature — see + /// the design documentation), but the port label now reflects the true, full dotted reference + /// rather than discarding everything after the container's name. + /// + [Fact] + public void InterconnectionView_BuildLayout_CrossBoundaryEndpoint_LabelReflectsNestedTarget() + { + // Arrange: Computer { board : Motherboard{cpu, chipset, ...}, psu, connect psu to board.cpu } + var strategy = new InterconnectionViewLayoutStrategy(); + var motherboard = new SysmlDefinitionNode + { + Name = "Motherboard", + QualifiedName = "M::Motherboard", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "cpu", QualifiedName = "M::Motherboard::cpu", FeatureKeyword = "part", FeatureTyping = "Cpu" }, + new SysmlFeatureNode { Name = "chipset", QualifiedName = "M::Motherboard::chipset", FeatureKeyword = "part", FeatureTyping = "Chipset" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "cpu", EndpointB = "chipset" } + ] + }; + var computer = new SysmlDefinitionNode + { + Name = "Computer", + QualifiedName = "M::Computer", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "board", QualifiedName = "M::Computer::board", FeatureKeyword = "part", FeatureTyping = "Motherboard" }, + new SysmlFeatureNode { Name = "psu", QualifiedName = "M::Computer::psu", FeatureKeyword = "part", FeatureTyping = "PowerSupply" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "psu", EndpointB = "board.cpu" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["M::Computer"] = computer, + ["M::Motherboard"] = motherboard + } + }; + var context = new ViewContext("ComputerInterconnectionView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: the connector still terminates at the "board" container box (one line at the root + // level, no exception), but the target-side port label is the true nested target "cpu", not + // discarded. Only the root box's own (non-recursive) Children are inspected here, since the + // nested Motherboard's own internal cpu-chipset connector is a separate, deeper connector + // that CollectLines/CollectPorts would otherwise also surface via recursion. + var rootBox = Assert.IsType(Assert.Single(layout.Nodes)); + var lines = rootBox.Children.OfType().ToList(); + Assert.Single(lines); + + var ports = rootBox.Children.OfType().ToList(); + Assert.Equal(2, ports.Count); + Assert.Contains(ports, p => p.ExternalLabel == "cpu"); } /// @@ -133,7 +462,7 @@ public void InterconnectionView_BuildLayout_PartBoxes_DoNotOverlap() 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(); + var partBoxes = CollectBoxes(layout.Nodes).Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); for (var i = 0; i < partBoxes.Count; i++) { for (var j = i + 1; j < partBoxes.Count; j++) @@ -285,7 +614,7 @@ public void InterconnectionView_BuildLayout_NoNesting_ProducesFlatLeafBoxes() var layout = strategy.BuildLayout(context, options); // Assert: every rounded part box is a leaf (no children). - var partBoxes = layout.Nodes.OfType().Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); + var partBoxes = CollectBoxes(layout.Nodes).Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); Assert.Equal(2, partBoxes.Count); Assert.All(partBoxes, b => Assert.Empty(b.Children)); } @@ -439,11 +768,11 @@ public void InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_Se var layout = strategy.BuildLayout(context, options); - var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + var container = CollectBoxes(layout.Nodes).First(b => b.Keyword == "part def"); Assert.Equal("SysC", container.Label); // Only c1 (SysC's part) is rendered, none of SysA's own parts (a1/a2/a3). - var partLabels = layout.Nodes.OfType() + var partLabels = CollectBoxes(layout.Nodes) .Where(b => b.Shape == BoxShape.RoundedRectangle) .Select(b => b.Label) .ToList(); @@ -455,8 +784,7 @@ public void InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_Se /// Finds the rounded part box with the given label across the whole layout tree. private static LayoutBox FindPartBox(LayoutTree layout, string label) { - var box = layout.Nodes - .OfType() + var box = CollectBoxes(layout.Nodes) .FirstOrDefault(b => b.Shape == BoxShape.RoundedRectangle && b.Label == label); Assert.NotNull(box); return box; @@ -469,6 +797,83 @@ private static bool Overlaps(LayoutBox a, LayoutBox b) => a.Y < b.Y + b.Height && b.Y < a.Y + a.Height; + /// + /// Recursively collects all nodes from a node list, including those + /// nested inside a container box's . + /// + 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; + } + + /// + /// Recursively collects all nodes from a node list, including those + /// nested inside a container box's . + /// + private static IReadOnlyList CollectPorts(IReadOnlyList nodes) + { + var result = new List(); + void Walk(IReadOnlyList ns) + { + foreach (var n in ns) + { + switch (n) + { + case LayoutPort port: + result.Add(port); + break; + case LayoutBox box: + Walk(box.Children); + break; + } + } + } + + Walk(nodes); + return result; + } + + /// + /// Recursively collects all nodes from a node list, including those + /// nested inside a container box's . + /// + private static IReadOnlyList CollectLines(IReadOnlyList nodes) + { + var result = new List(); + void Walk(IReadOnlyList ns) + { + foreach (var n in ns) + { + switch (n) + { + case LayoutLine line: + result.Add(line); + break; + case LayoutBox box: + Walk(box.Children); + break; + } + } + } + + Walk(nodes); + return result; + } + /// /// Builds a workspace with two candidate roots: M::SysA (two connections, three /// parts — the heuristic's default pick) and M::SysB (one connection, two parts), @@ -529,7 +934,7 @@ public void InterconnectionView_BuildLayout_NullViewNode_PicksHeuristicRootUncha var layout = strategy.BuildLayout(context, options); - var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + var container = CollectBoxes(layout.Nodes).First(b => b.Keyword == "part def"); Assert.Equal("SysA", container.Label); } @@ -556,7 +961,7 @@ public void InterconnectionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExpose var layout = strategy.BuildLayout(context, options); - var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + var container = CollectBoxes(layout.Nodes).First(b => b.Keyword == "part def"); Assert.Equal("SysB", container.Label); } @@ -632,7 +1037,7 @@ public void InterconnectionView_BuildLayout_ExposeBothSameDepthSiblings_ScoreBre var layout = strategy.BuildLayout(context, options); - var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + var container = CollectBoxes(layout.Nodes).First(b => b.Keyword == "part def"); Assert.Equal("AB", container.Label); } @@ -658,13 +1063,13 @@ public void InterconnectionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_S var layout = strategy.BuildLayout(context, options); - var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + var container = CollectBoxes(layout.Nodes).First(b => b.Keyword == "part def"); Assert.Equal("SysB", container.Label); // And the part collection is narrowed to just b1 (b2 dropped, connection dropped with it). - var partBoxes = layout.Nodes.OfType().Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); + var partBoxes = CollectBoxes(layout.Nodes).Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); Assert.Single(partBoxes); - Assert.Empty(layout.Nodes.OfType()); + Assert.Empty(CollectLines(layout.Nodes)); } /// @@ -715,10 +1120,10 @@ public void InterconnectionView_BuildLayout_ExposeSinglePart_NarrowsToThatPart() var scoped = strategy.BuildLayout(context, options); var full = strategy.BuildLayout(new ViewContext("full", workspace), options); - var scopedParts = scoped.Nodes.OfType().Count(b => b.Shape == BoxShape.RoundedRectangle); - var fullParts = full.Nodes.OfType().Count(b => b.Shape == BoxShape.RoundedRectangle); + var scopedParts = CollectBoxes(scoped.Nodes).Count(b => b.Shape == BoxShape.RoundedRectangle); + var fullParts = CollectBoxes(full.Nodes).Count(b => b.Shape == BoxShape.RoundedRectangle); Assert.True(scopedParts < fullParts, $"expected scoped ({scopedParts}) < full ({fullParts})"); - Assert.Single(scoped.Nodes.OfType(), b => b.Shape == BoxShape.RoundedRectangle); + Assert.Single(CollectBoxes(scoped.Nodes), b => b.Shape == BoxShape.RoundedRectangle); } /// @@ -747,9 +1152,9 @@ public void InterconnectionView_BuildLayout_ExposeMultipleParts_UnionsBothSubtre var layout = strategy.BuildLayout(context, options); - var partBoxes = layout.Nodes.OfType().Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); + var partBoxes = CollectBoxes(layout.Nodes).Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); Assert.Equal(2, partBoxes.Count); - Assert.Single(layout.Nodes.OfType()); + Assert.Single(CollectLines(layout.Nodes)); } /// @@ -782,7 +1187,7 @@ public void InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingTo var layout = strategy.BuildLayout(context, options); - var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + var container = CollectBoxes(layout.Nodes).First(b => b.Keyword == "part def"); Assert.Equal("SysB", container.Label); } }