diff --git a/changes/unreleased/generalization-routes.fixed.md b/changes/unreleased/generalization-routes.fixed.md
new file mode 100644
index 0000000000..8dc887311a
--- /dev/null
+++ b/changes/unreleased/generalization-routes.fixed.md
@@ -0,0 +1 @@
+- A migrated block definition diagram keeps Cameo's routes for its generalization lines: the migrator writes each as a `DiagramLayout::Route` about the specific definition naming the general it is drawn to (`ref :>> general : Sup;`), the report counts them as written rather than "no v2 member", and the positioned tree steers the specialization edge to that general along the waypoints — one Route per general of a subtype with several, and a view's own Route when the subtype is drawn in more than one. The layout check reports a Route naming a general the view draws no edge to.
diff --git a/changes/unreleased/keyword-line-fit.fixed.md b/changes/unreleased/keyword-line-fit.fixed.md
new file mode 100644
index 0000000000..e4b4647100
--- /dev/null
+++ b/changes/unreleased/keyword-line-fit.fixed.md
@@ -0,0 +1 @@
+- A stated box keeps its keyword line beside a name that wraps: the DOT writer shrinks the name and the keyword together, from the node's size down to the floor, and drops the keyword only when even the floor holds no line for it, so a Cameo-sized box whose two-word name wraps still reads `«part def»` above it. Detail lines and compartments are fitted after, as before.
diff --git a/docs/project/diagram-layout-annotations.md b/docs/project/diagram-layout-annotations.md
index 8c820b66ef..66e9f8a178 100644
--- a/docs/project/diagram-layout-annotations.md
+++ b/docs/project/diagram-layout-annotations.md
@@ -62,6 +62,7 @@ library package DiagramLayout {
metadata def Route {
attribute points : ScalarValues::Real[0..*] ordered nonunique;
+ ref general : Base::Anything[0..1];
}
metadata def Canvas {
@@ -191,6 +192,16 @@ Decisions, each with its reason:
waypoints are one flattened sequence (`x0, y0, x1, y1, …`) because a metadata feature
holds scalars: a sequence of pairs would need a second attribute definition for the
pair, which no other tool would read as a point.
+- **A specialization edge is routed from its specific element, naming the general.** A
+ `:>`, `:` or `:>>` clause declares no element of its own that an `about` could name, so
+ the Route of the line a tree draws for it is stated about the specific element and
+ types its `general` with the element the line is drawn to:
+ `metadata Route about Sub { points = (…); ref :>> general : Sup; }`. One Route per
+ general steers each line of a subtype with several, and a Route naming a general
+ never steers an edge the specific element declares itself (a `connection def Sub :>
+ Base` keeps its own Route for its line). A standalone KerML `specialization` member
+ could be named instead, but SysML v2 textual notation does not admit one, and the
+ pilot rejects it in a `.sysml` file.
- **`Route.points` is `nonunique`.** The specification's default for a feature is
uniqueness (KerML 7.3.4.4), and OpenSysML's type checker enforces it on a sequence
written at a unique feature. A vertical segment repeats its x coordinate and a route
diff --git a/docs/project/pilot-differential-baseline.json b/docs/project/pilot-differential-baseline.json
index 2ad11714e8..a9da421ccf 100644
--- a/docs/project/pilot-differential-baseline.json
+++ b/docs/project/pilot-differential-baseline.json
@@ -76,7 +76,7 @@
"dir": "internal/workspace/libs/stdlib/OpenSysML Libraries",
"origin": "ours",
"files": 16,
- "digest": "sha256:f8cd19f0cdbccc151b164bc1b946218ba64ba6de1199a8212815723236ecd48d"
+ "digest": "sha256:478be4a232db162cf4de51048786b7f78013bb8495de861c2647ef0e73523925"
}
],
"recorded": "2026-10-16"
diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md
index 5114667c16..343d9fe4b7 100644
--- a/docs/reference/sysml-v1-migration.md
+++ b/docs/reference/sysml-v1-migration.md
@@ -476,9 +476,12 @@ Edges with no v2 member of their own are not given one: a Generalization or Inte
is a `:>` clause or a port's conjugation, a Composition or Aggregation between blocks is the
`part`/`ref` end usage, a constraint or information flow edge nothing realizes is not
written, and a decision's `else` branch is a clause of the node it leaves, not a member. Their
-placements on a diagram expose the ends as before; their routes are reported (below), not
+placements on a diagram expose the ends as before. A Generalization's route is written about the
+specific definition, naming the general its `:>` is drawn to (`metadata DiagramLayout::Route
+about Sub { points = (…); ref :>> general : Sup; }`), one per general, when a block definition
+diagram shows both ends and the `:>` was written; the others' routes are reported (below), not
attached to a member that is not an edge. The rendered view still draws them: a tree draws a
-specialization from each exposed element to the general it specializes, and a composition
+specialization from each exposed element to the general it specializes, along its Route, and a composition
(filled diamond, labelled with the end usage's name and multiplicity) or reference (hollow
diamond) from each to the definition typing a part or `ref` it owns, wherever both ends are
drawn, and draws a `connection def` whose two ends are typed by drawn definitions as a line
diff --git a/internal/check/edit/layout.go b/internal/check/edit/layout.go
index 76cbf8f180..7d3a012223 100644
--- a/internal/check/edit/layout.go
+++ b/internal/check/edit/layout.go
@@ -338,10 +338,11 @@ func (m Model) exposes(sem *semantics.Model, viewSym, sym *symbols.Symbol) bool
// layoutSite is the annotation of type typeFQN the operation rewrites: the one
// stated in viewSym's body about sym, or, for an inline operation, the one
-// applying in every view; nil when none is stated in m's document.
+// applying in every view; nil when none is stated in m's document. A Route
+// naming a general steers a specialization edge, which no operation edits.
func (m Model) layoutSite(sem *semantics.Model, sym, viewSym *symbols.Symbol, typeFQN string) *semantics.LayoutSite {
for _, site := range sem.LayoutSitesOf(sym) {
- if site.TypeFQN != typeFQN || symbols.DocNameOf(site.Scope) != m.Source.Name() {
+ if site.TypeFQN != typeFQN || site.General != nil || symbols.DocNameOf(site.Scope) != m.Source.Name() {
continue
}
if viewSym == nil && site.View == nil {
diff --git a/internal/check/edit/layout_test.go b/internal/check/edit/layout_test.go
index 0f1cac2c2c..67ec823f23 100644
--- a/internal/check/edit/layout_test.go
+++ b/internal/check/edit/layout_test.go
@@ -193,6 +193,24 @@ func TestSetRouteOnTransitionAndConnection(t *testing.T) {
requireReplaced(t, routed, "points = (1, 2, 3, 4);", "points = (9, 8);", got)
}
+// TestSetRouteLeavesASpecializationRoute checks that a Route naming a general,
+// which steers the usage's edge to that general, is not the one setRoute
+// rewrites: the usage's own edge gets a Route of its own.
+func TestSetRouteLeavesASpecializationRoute(t *testing.T) {
+ specialized := strings.Replace(plantModel,
+ " part pump : Pump;\n",
+ " part pump : Pump { @DiagramLayout::Route { points = (1, 2, 3, 4); ref :>> general : Pump; } }\n", 1)
+ route := &semantics.Route{Points: []semantics.Waypoint{{X: 100, Y: 50}, {X: 150, Y: 75.5}}}
+ got := applyLayout(t, specialized, SetRoute("Plant::Loop::pump", "", route))
+ if !strings.Contains(got, "points = (1, 2, 3, 4); ref :>> general : Pump;") {
+ t.Fatalf("the Route naming the general was rewritten:\n%s", got)
+ }
+ if strings.Count(got, "DiagramLayout::Route") != strings.Count(specialized, "DiagramLayout::Route")+1 ||
+ !strings.Contains(got, "points = (100, 50, 150, 75.5);") {
+ t.Fatalf("no Route of its own was added for pump's edge:\n%s", got)
+ }
+}
+
// unnamedModel is plantModel with a transition and a connection no qualified
// name reaches: the transition is unnamed, the part declaring the connection is.
var unnamedModel = strings.Replace(strings.Replace(plantModel,
diff --git a/internal/check/passes/diagram/layout.go b/internal/check/passes/diagram/layout.go
index 96cb10326c..fb76b411c8 100644
--- a/internal/check/passes/diagram/layout.go
+++ b/internal/check/passes/diagram/layout.go
@@ -96,6 +96,9 @@ func (c *layoutChecker) check(sym *symbols.Symbol) {
if owner := c.memberOwner(site, sym); owner != nil {
key.owner = owner.Decl
}
+ if site.General != nil {
+ key.general = site.General.Decl
+ }
if _, dup := firstInView[key]; !dup {
firstInView[key] = site
} else if c.inDoc(site.Scope) {
@@ -141,6 +144,7 @@ type viewKey struct {
view ast.Node
typeFQN string
owner ast.Node
+ general ast.Node
}
// memberOwner is the namespace a Layout positions sym as a member of, nil for
@@ -157,6 +161,10 @@ func (c *layoutChecker) memberOwner(site *semantics.LayoutSite, sym *symbols.Sym
// `about` annotation is stated in, any kind for an annotation applying in every
// view. A Layout wants a node, a Route an edge, a Style or a Note either.
func (c *layoutChecker) checkPlaced(site *semantics.LayoutSite, sym *symbols.Symbol) {
+ if site.General != nil {
+ c.checkSpecializationPlaced(site, sym)
+ return
+ }
wantNode := site.TypeFQN != semantics.RouteFQN
wantEdge := site.TypeFQN != semantics.LayoutFQN
verb := map[string]string{
@@ -197,6 +205,31 @@ func (c *layoutChecker) checkPlaced(site *semantics.LayoutSite, sym *symbols.Sym
}
}
+// checkSpecializationPlaced warns when the rendering a Route naming a general
+// applies to draws no edge from the element to that general.
+func (c *layoutChecker) checkSpecializationPlaced(site *semantics.LayoutSite, sym *symbols.Symbol) {
+ if site.View == nil {
+ if !c.renderer.DrawsSpecializationAnywhere(sym, site.General) {
+ c.warnf(site.Node.Span(), layoutUnplacedCode,
+ "Route steers the edge from %s to %s, which no rendering draws", c.describe(sym), c.describe(site.General))
+ }
+ return
+ }
+ kind, _, err := c.renderer.KindOf(site.View)
+ if err != nil {
+ return
+ }
+ drawn, ok := c.drawnIn(site.View)
+ if !ok {
+ return
+ }
+ if !drawn.Specialization(sym, site.General) {
+ c.warnf(site.Node.Span(), layoutUnplacedCode,
+ "Route steers the edge from %s to %s, which the %s rendering of %s does not draw",
+ c.describe(sym), c.describe(site.General), kind, c.describe(site.View))
+ }
+}
+
// drawnIn is what the rendering of v draws, rendered once per view; false when
// the view does not render, which other passes report.
func (c *layoutChecker) drawnIn(v *symbols.Symbol) (*view.Drawn, bool) {
diff --git a/internal/ir/view/dot.go b/internal/ir/view/dot.go
index 860d40be27..3a05b0e754 100644
--- a/internal/ir/view/dot.go
+++ b/internal/ir/view/dot.go
@@ -1537,13 +1537,15 @@ const (
dotCompartmentChrome = 4 * dotCellPadding
)
-// dotFittedLabel is a node's label composed to fit a stated box: the head wrapped
-// at the box's width and shrunk from the node's size to the largest at which it
-// fits, the keyword and detail lines after it while height remains. A head too
-// tall even at the floor is cut to the lines that fit and ellipsized. A Cameo
-// label with detail lines is set in the compartment table, so it is fitted to
-// the box less the table's chrome; when no detail line fits there, no table is
-// written and the title alone is fitted to the whole box.
+// dotFittedLabel is a node's label composed to fit a stated box, in a fixed
+// order: the head and its keyword line together, wrapped at the box's width
+// and shrunk from the node's size to the largest at which both fit; failing
+// that at the floor, the head alone, shrunk the same way, and the keyword
+// dropped; then the detail lines, then the compartments, each while height
+// remains. A head too tall even at the floor is cut to the lines that fit and
+// ellipsized. A Cameo label with detail lines is set in the compartment table,
+// so it is fitted to the box less the table's chrome; when no detail line fits
+// there, no table is written and the title alone is fitted to the whole box.
func (l labeller) dotFittedLabel(node *Node, width, height float64) (label string, clipped int) {
if l.compartmented(node) {
parts := l.fitParts(node, width-dotCompartmentPadding, height-dotCompartmentChrome)
@@ -1558,37 +1560,28 @@ func (l labeller) dotFittedLabel(node *Node, width, height float64) (label strin
return dotLabelAttribute(l.assemble(parts)), parts.clipped
}
-// fitParts is a node's label fitted to a box, by part.
+// fitParts is a node's label fitted to a box, by part, in dotFittedLabel's order.
func (l labeller) fitParts(node *Node, width, height float64) labelParts {
base := l.sizeOf(node)
- size, head, fits := dotFitText(l.headLines(node), true, width, height, base)
+ size, head, keyword, fits := l.fitHead(node, width, height, base)
var parts labelParts
parts.head = l.sized(base, size, ""+dotEscapeLines(head)+"")
left := height - float64(len(head))*dotLineHeight(size)
- rest := l.details(node)
- keyword := l.keyword(node)
- if keyword != "" {
- rest = append([]string{keyword}, rest...)
+ if len(keyword) > 0 {
+ keywordSize := l.fittedKeywordSize(size, base)
+ left -= float64(len(keyword)) * dotLineHeight(keywordSize)
+ parts.keyword = l.sized(base, keywordSize, l.keywordText(dotEscapeLines(keyword)))
}
room := fits
- for i, line := range rest {
- lineSize := size
- if i == 0 && keyword != "" {
- lineSize = math.Round(size * l.keywordSize() / base)
- }
- wrapped := dotWrap(line, width, lineSize, false)
- used := float64(len(wrapped)) * dotLineHeight(lineSize)
- if !room || used > left || dotOverruns(wrapped, width, lineSize, false) {
+ for _, line := range l.details(node) {
+ wrapped := dotWrap(line, width, size, false)
+ used := float64(len(wrapped)) * dotLineHeight(size)
+ if !room || used > left || dotOverruns(wrapped, width, size, false) {
room = false
break
}
left -= used
- text := dotEscapeLines(wrapped)
- if i == 0 && keyword != "" {
- parts.keyword = l.sized(base, lineSize, l.keywordText(text))
- continue
- }
- parts.details = append(parts.details, l.sized(base, lineSize, text))
+ parts.details = append(parts.details, l.sized(base, size, dotEscapeLines(wrapped)))
}
nameSize := math.Round(size * l.keywordSize() / base)
for _, compartment := range l.compartments(node) {
@@ -1614,6 +1607,44 @@ func (l labeller) fitParts(node *Node, width, height float64) labelParts {
return parts
}
+// fitHead fits a node's head and, when the node shows one, its keyword line
+// into a box together: at each size from the node's down to the floor the head
+// is wrapped at the box's width and the keyword set at its size under that,
+// and the largest size at which both fit is taken, whole words before broken
+// ones. When no size holds them both the keyword is dropped and the head alone
+// is fitted as dotFitText fits it. The keyword is returned wrapped, empty when
+// dropped or not shown.
+func (l labeller) fitHead(node *Node, width, height, base float64) (size float64, head, keyword []string, fits bool) {
+ text := l.headLines(node)
+ if word := l.keyword(node); word != "" {
+ for _, whole := range []bool{true, false} {
+ for size = base; size >= dotFitFloor; size-- {
+ head, ok := dotWrapAll(text, width, size, true, whole)
+ if !ok {
+ continue
+ }
+ keywordSize := l.fittedKeywordSize(size, base)
+ keyword := dotWrap(word, width, keywordSize, false)
+ if dotOverruns(keyword, width, keywordSize, false) {
+ continue
+ }
+ used := float64(len(head))*dotLineHeight(size) + float64(len(keyword))*dotLineHeight(keywordSize)
+ if used <= height {
+ return size, head, keyword, true
+ }
+ }
+ }
+ }
+ size, head, fits = dotFitText(text, true, width, height, base)
+ return size, head, nil, fits
+}
+
+// fittedKeywordSize is the keyword line's font size under a head shrunk from
+// its node's size: shrunk in the same proportion, never below the floor.
+func (l labeller) fittedKeywordSize(size, base float64) float64 {
+ return math.Max(dotFitFloor, math.Round(size*l.keywordSize()/base))
+}
+
// compartmented reports whether a node's label is set in a compartment table:
// when it has compartments of member rows, or detail lines under its title in
// Cameo's skin.
@@ -1691,16 +1722,8 @@ func (l labeller) assemble(parts labelParts) string {
func dotFitText(text []string, bold bool, width, height, from float64) (size float64, lines []string, fits bool) {
for _, whole := range []bool{true, false} {
for size = from; size >= dotFitFloor; size-- {
- lines = lines[:0]
- broken := false
- for _, entry := range text {
- if whole && dotBreaksAWord(entry, width, size, bold) {
- broken = true
- break
- }
- lines = append(lines, dotWrap(entry, width, size, bold)...)
- }
- if broken || dotOverruns(lines, width, size, bold) {
+ var ok bool
+ if lines, ok = dotWrapAll(text, width, size, bold, whole); !ok {
continue
}
if float64(len(lines))*dotLineHeight(size) <= height {
@@ -1709,10 +1732,7 @@ func dotFitText(text []string, bold bool, width, height, from float64) (size flo
}
}
size = dotFitFloor
- lines = lines[:0]
- for _, entry := range text {
- lines = append(lines, dotWrap(entry, width, size, bold)...)
- }
+ lines, _ = dotWrapAll(text, width, size, bold, false)
down := max(1, int(height/dotLineHeight(size)))
cut := len(lines) > down
if cut {
@@ -1726,6 +1746,19 @@ func dotFitText(text []string, bold bool, width, height, from float64) (size flo
return size, lines, false
}
+// dotWrapAll wraps each entry of a text at a box's width and concatenates the
+// wrappings; it fails when a line is still wider than the box or, when words
+// are to stay whole, when any entry must break one.
+func dotWrapAll(text []string, width, size float64, bold, whole bool) (lines []string, ok bool) {
+ for _, entry := range text {
+ if whole && dotBreaksAWord(entry, width, size, bold) {
+ return nil, false
+ }
+ lines = append(lines, dotWrap(entry, width, size, bold)...)
+ }
+ return lines, !dotOverruns(lines, width, size, bold)
+}
+
// dotOverruns reports whether any line is wider than a width at a font size.
func dotOverruns(lines []string, width, size float64, bold bool) bool {
for _, line := range lines {
diff --git a/internal/ir/view/dot_fit_test.go b/internal/ir/view/dot_fit_test.go
index 1acc8561e2..21406947ac 100644
--- a/internal/ir/view/dot_fit_test.go
+++ b/internal/ir/view/dot_fit_test.go
@@ -15,11 +15,12 @@ func stated(node *Node, width, height float64) *Node {
}
// A label composed for a stated box fits it: the head word-wraps at the box's
-// width, keeps 14pt while the wrapped lines fit the height and shrinks a point at
-// a time to 8pt when they do not; the keyword and detail lines follow only while
-// height remains; a head that overruns at 8pt is cut to the lines that fit and
-// ellipsized. The box itself is never resized, and its margin is zeroed so the
-// whole box is the label's, as the fit assumes.
+// width and, with its keyword line, keeps 14pt while the lines fit the height
+// and shrinks a point at a time to 8pt when they do not; the keyword is dropped
+// only when even 8pt holds no room for it, and the detail lines follow only
+// while height remains; a head that overruns at 8pt is cut to the lines that
+// fit and ellipsized. The box itself is never resized, and its margin is zeroed
+// so the whole box is the label's, as the fit assumes.
func TestDOTFitsTheLabelToAStatedBox(t *testing.T) {
cases := []struct {
name string
@@ -30,8 +31,10 @@ func TestDOTFitsTheLabelToAStatedBox(t *testing.T) {
`label=<«action»
call : doTracking>, margin=0, pos="100,-40!", pin=true, width=2.7777777777777777, height=1.1111111111111112, fixedsize=true];`},
{"head wraps at the width", stated(&Node{ID: "n", Kind: "action", Name: "Execute Find and Identify Algorithm"}, 100, 100),
`label=<«action»
Execute
Find and
Identify
Algorithm>`},
- {"keyword dropped for want of height", stated(&Node{ID: "n", Kind: "action", Name: "call", Type: "doTracking"}, 200, 24),
- `label=<call : doTracking>`},
+ {"keyword kept by shrinking the head with it", stated(&Node{ID: "n", Kind: "action", Name: "call", Type: "doTracking"}, 200, 24),
+ `label=<«action»
call : doTracking>`},
+ {"keyword dropped only when the floor holds no room for it", stated(&Node{ID: "n", Kind: "action", Name: "call", Type: "doTracking"}, 200, 16),
+ `label=<call : doTracking>`},
{"detail only while height remains", stated(&Node{ID: "n", Kind: "state", Name: "idle", Detail: "entry, do"}, 200, 40),
`label=<«state»
idle>`},
{"detail when it fits", stated(&Node{ID: "n", Kind: "state", Name: "idle", Detail: "entry, do"}, 200, 60),
@@ -41,16 +44,17 @@ func TestDOTFitsTheLabelToAStatedBox(t *testing.T) {
{"a name too long for the floor is ellipsized", stated(&Node{ID: "n", Kind: "attribute", Name: "'a name that runs on well past the width of the row it is drawn in'"}, 120, 14),
`label=<'a name that runs on we…>, margin=0, pos="60,-7!", pin=true, width=1.6666666666666667, height=0.19444444444444445, fixedsize=true];`},
{"a word wider than the box is broken across lines", stated(&Node{ID: "n", Kind: "action", Name: "Reconfiguration"}, 60, 60),
- `label=<Reconfi
guratio
n>`},
+ `label=<«action»
Reconfig
uration>`},
{"shrinking to keep a word whole comes before breaking it", stated(&Node{ID: "n", Kind: "part", Name: "EventStream"}, 77, 32),
- `label=<«part»
EventStream>`},
- {"wrapping comes before shrinking", stated(&Node{ID: "n", Kind: "part", Name: "pump", Type: "Pump"}, 60, 40),
- `label=<pump
: Pump>`},
+ `label=<«part»
EventStream>`},
+ {"the head wraps and shrinks to keep the keyword", stated(&Node{ID: "n", Kind: "part", Name: "pump", Type: "Pump"}, 60, 40),
+ `label=<«part»
pump
: Pump>`},
// Twenty bold 14pt glyphs measure 196pt: the whole width is the label's, no margin off it.
- {"a head filling the width keeps its size", stated(&Node{ID: "n", Kind: "action", Name: "doughboundhoundpound"}, 196, 20),
- `label=<doughboundhoundpound>, margin=0, pos="98,-10!"`},
- {"a head a point over the width shrinks", stated(&Node{ID: "n", Kind: "action", Name: "doughboundhoundpound"}, 195, 20),
- `label=<doughboundhoundpound>, margin=0, pos="97.5,-10!"`},
+ // The 18pt height holds one 14pt line, not the keyword line too even at the floor.
+ {"a head filling the width keeps its size", stated(&Node{ID: "n", Kind: "action", Name: "doughboundhoundpound"}, 196, 18),
+ `label=<doughboundhoundpound>, margin=0, pos="98,-9!"`},
+ {"a head a point over the width shrinks", stated(&Node{ID: "n", Kind: "action", Name: "doughboundhoundpound"}, 195, 18),
+ `label=<doughboundhoundpound>, margin=0, pos="97.5,-9!"`},
}
for _, tc := range cases {
dot, err := (&Rendering{View: "V", Kind: KindAction, Roots: []*Node{tc.node}}).DOTWith(fullNotation)
@@ -68,9 +72,18 @@ func TestDOTFitsTheLabelToAStatedBox(t *testing.T) {
t.Errorf("%s: the stated box keeps Graphviz's margin, which the fit did not allow for:\n%s", tc.name, dot)
}
}
+ // With keywords hidden the head alone is fitted, at the size it holds.
+ hidden := stated(&Node{ID: "n", Kind: "action", Name: "call", Type: "doTracking"}, 200, 24)
+ dot, err := (&Rendering{View: "V", Kind: KindAction, Roots: []*Node{hidden}, Shown: Shown{ElementTypings: true}}).DOTWith(Options{})
+ if err != nil {
+ t.Fatalf("DOT: %v", err)
+ }
+ if want := `label=<call : doTracking>, margin=0`; !strings.Contains(dot, want) {
+ t.Errorf("keyword-less DOT lacks %q:\n%s", want, dot)
+ }
// A node with no stated size keeps the label-fitted box and the plain label.
loose := &Node{ID: "n", Kind: "action", Name: "Execute Find and Identify Algorithm", Geometry: &Geometry{X: 0, Y: 0}}
- dot, err := (&Rendering{View: "V", Kind: KindAction, Roots: []*Node{loose}}).DOTWith(fullNotation)
+ dot, err = (&Rendering{View: "V", Kind: KindAction, Roots: []*Node{loose}}).DOTWith(fullNotation)
if err != nil {
t.Fatalf("DOT: %v", err)
}
@@ -288,8 +301,8 @@ func TestDOTHeadsAnEnclosingBoxAboveItsMembers(t *testing.T) {
}
checkDOTSyntax(t, dot)
for _, want := range []string{
- // The 40px strip holds the head wrapped to two 14pt lines (33.6px), not the keyword line too; the label sits at the top.
- `"outer" [style="rounded,filled", label=<'summit Installation' : 'Summit
Installation'>, margin=0, labelloc=t, pos="150,-100!", pin=true, width=4.166666666666667, height=2.7777777777777777, fixedsize=true];`,
+ // The 40px strip holds the keyword line over the head on one 12pt line (34.5px); the label sits at the top.
+ `"outer" [style="rounded,filled", label=<«part»
'summit Installation' : 'Summit Installation'>, margin=0, labelloc=t, pos="150,-100!", pin=true, width=4.166666666666667, height=2.7777777777777777, fixedsize=true];`,
// A box holding nothing is fitted to the whole of it and centred; the members keep their stated boxes.
`"beside" [style="rounded,filled", label=<«part»
rack>, margin=0, pos="550,-100!"`,
`"computer" [style="rounded,filled", label=<«part»
computer>, margin=0, pos="60,-75!", pin=true, width=1.1111111111111112, height=0.4166666666666667, fixedsize=true];`,
@@ -332,7 +345,7 @@ func TestDOTHeadsAnEnclosingBoxAboveItsMembers(t *testing.T) {
t.Fatalf("DOT: %v", err)
}
checkDOTSyntax(t, dot)
- if want := "subgraph \"cluster_outer\" {\n label=<'summit Installation' : 'Summit
Installation'>;\n"; !strings.Contains(dot, want) {
+ if want := "subgraph \"cluster_outer\" {\n label=<«part»
'summit Installation' : 'Summit Installation'>;\n"; !strings.Contains(dot, want) {
t.Errorf("DOT lacks %q:\n%s", want, dot)
}
// A cluster's label has no outside to go to: a strip thinner than a line still gets one line at the floor.
diff --git a/internal/ir/view/geometry.go b/internal/ir/view/geometry.go
index 54df70c82a..5b23240e48 100644
--- a/internal/ir/view/geometry.go
+++ b/internal/ir/view/geometry.go
@@ -257,6 +257,24 @@ func (r *Renderer) routeOf(view, elem *symbols.Symbol, out *Rendering) []Point {
if !ok {
return nil
}
+ return r.routePoints(site, elem, out)
+}
+
+// specializationRouteOf is the waypoints the edge drawn from elem to general,
+// for the specialization, typing or subsetting elem states, follows in view:
+// those of a Route about elem naming general, nil when none gives any.
+func (r *Renderer) specializationRouteOf(view, elem, general *symbols.Symbol, out *Rendering) []Point {
+ out.drawn.noteSpecialization(elem, general)
+ site, ok := r.model.SpecializationRouteOf(view, elem, general)
+ if !ok {
+ return nil
+ }
+ return r.routePoints(site, elem, out)
+}
+
+// routePoints is the waypoints a Route site about elem binds, nil when it
+// reads none; its problems are noticed on out.
+func (r *Renderer) routePoints(site *semantics.LayoutSite, elem *symbols.Symbol, out *Rendering) []Point {
r.noteLayoutProblems(site, elem, out)
if site.Route == nil {
return nil
diff --git a/internal/ir/view/place.go b/internal/ir/view/place.go
index 43c73f1a08..0cc581095a 100644
--- a/internal/ir/view/place.go
+++ b/internal/ir/view/place.go
@@ -12,6 +12,7 @@ import (
type Drawn struct {
nodes, edges map[ast.Node]bool
members map[ast.Node]map[ast.Node]bool // member decl -> owner decls it is drawn as a member of
+ generals map[ast.Node]map[ast.Node]bool // element decl -> decls of the generals it is drawn with an edge to
}
// Node reports whether the rendering draws sym as a node a Layout positions.
@@ -40,6 +41,26 @@ func (d *Drawn) Edge(sym *symbols.Symbol) bool {
return d != nil && sym != nil && d.edges[sym.Decl]
}
+// Specialization reports whether the rendering draws an edge from sym to
+// general for the specialization, typing or subsetting sym states: the edge a
+// Route about sym naming general steers.
+func (d *Drawn) Specialization(sym, general *symbols.Symbol) bool {
+ return d != nil && sym != nil && general != nil && d.generals[sym.Decl][general.Decl]
+}
+
+// noteSpecialization records that the rendering drew an edge from elem to general.
+func (d *Drawn) noteSpecialization(elem, general *symbols.Symbol) {
+ if d == nil || elem == nil || general == nil || elem.Decl == nil || general.Decl == nil {
+ return
+ }
+ generals, ok := d.generals[elem.Decl]
+ if !ok {
+ generals = map[ast.Node]bool{}
+ d.generals[elem.Decl] = generals
+ }
+ generals[general.Decl] = true
+}
+
// note records that the rendering drew elem; a nil Drawn collects nothing.
func (d *Drawn) note(elem *symbols.Symbol, asEdge bool) {
if d == nil || elem == nil || elem.Decl == nil {
@@ -70,7 +91,7 @@ func (d *Drawn) noteMember(owner, member *symbols.Symbol) {
// Layout or Route stated in the view's body can apply to. A view that does
// not render is the error Render gives.
func (r *Renderer) DrawnIn(view *symbols.Symbol) (*Drawn, error) {
- drawn := &Drawn{nodes: map[ast.Node]bool{}, edges: map[ast.Node]bool{}, members: map[ast.Node]map[ast.Node]bool{}}
+ drawn := &Drawn{nodes: map[ast.Node]bool{}, edges: map[ast.Node]bool{}, members: map[ast.Node]map[ast.Node]bool{}, generals: map[ast.Node]map[ast.Node]bool{}}
if _, err := r.render(view, drawn); err != nil {
return nil, err
}
@@ -153,6 +174,27 @@ func (r *Renderer) mixedReferenceEdge(sym *symbols.Symbol) bool {
return false
}
+// DrawsSpecializationAnywhere reports whether some rendering draws an edge from
+// sym to general: the tree draws one for each specialization, typing or
+// subsetting between two elements it draws as nodes.
+func (r *Renderer) DrawsSpecializationAnywhere(sym, general *symbols.Symbol) bool {
+ if sym == nil || general == nil || !r.contentKind(sym) || !r.contentKind(general) {
+ return false
+ }
+ for _, rel := range semantics.RelationshipsOf(sym) {
+ if rel == nil || rel.Target == nil {
+ continue
+ }
+ if _, _, ok := structuralEdgeKind(rel.Kind); !ok {
+ continue
+ }
+ if target, resolved := r.generalizationTarget(sym, rel); resolved && target.Decl == general.Decl {
+ return true
+ }
+ }
+ return false
+}
+
// DrawsAnywhere reports whether some rendering kind draws sym as a node and
// whether some draws it as an edge: what an annotation applying in every view
// can position.
diff --git a/internal/ir/view/testdata/tree-generalization-second.dot.golden b/internal/ir/view/testdata/tree-generalization-second.dot.golden
new file mode 100644
index 0000000000..f43b35a264
--- /dev/null
+++ b/internal/ir/view/testdata/tree-generalization-second.dot.golden
@@ -0,0 +1,15 @@
+// view: MicroscopeViews::second
+// kind: tree
+// stated: render asTreeDiagram
+// canvas: unit=px w=300 h=200
+// layout: neato -n2
+digraph "MicroscopeViews::second" {
+ graph [fontname="Helvetica", layout=neato, inputscale=72, dpi=72];
+ node [shape=box, style=filled, fillcolor=white, color="#181818", fontname="Helvetica", fontsize=14, penwidth=0.5];
+ edge [color="#181818", fontname="Helvetica", fontsize=13, penwidth=1];
+ "canvas:0" [shape=point, style=invis, width=0, height=0, label="", pos="0,200!", pin=true];
+ "canvas:1" [shape=point, style=invis, width=0, height=0, label="", pos="300,0!", pin=true];
+ "n0" [label=<«part def»
Detector>, margin=0, pos="75,163.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n1" [label=<«part def»
'Backscattered Electron
Detector'>, margin=0, pos="200,43.5!", pin=true, width=2.2222222222222223, height=0.4583333333333333, fixedsize=true];
+ "n1" -> "n0" [arrowhead=empty, pos="e,75,147 200,60 200,60 200,110 200,110 200,110 75,110 75,110 75,110 75,137 75,137"];
+}
diff --git a/internal/ir/view/testdata/tree-generalization-unpositioned.dot.golden b/internal/ir/view/testdata/tree-generalization-unpositioned.dot.golden
new file mode 100644
index 0000000000..29b9bd1bf8
--- /dev/null
+++ b/internal/ir/view/testdata/tree-generalization-unpositioned.dot.golden
@@ -0,0 +1,19 @@
+// view: MicroscopeViews::unpositioned
+// kind: tree
+// stated: render asTreeDiagram
+// layout: dot
+digraph "MicroscopeViews::unpositioned" {
+ graph [fontname="Helvetica"];
+ node [shape=box, style=filled, fillcolor=white, color="#181818", fontname="Helvetica", fontsize=14, penwidth=0.5];
+ edge [color="#181818", fontname="Helvetica", fontsize=13, penwidth=1];
+ "n0" [label=<«part def»
Detector>];
+ "n1" [label=<«part def»
Sensor>];
+ "n2" [label=<«part def»
Instrument>];
+ "n3" [label=<«part def»
'Display System'>];
+ "n4" [label=<«part def»
'Scanning Electron Microscope'>];
+ "n5" [label=<«part def»
'Secondary Electron Detector'>];
+ "n6" [label=<«part def»
'Backscattered Electron Detector'>];
+ "n5" -> "n0" [arrowhead=empty];
+ "n6" -> "n0" [arrowhead=empty];
+ "n6" -> "n1" [arrowhead=empty];
+}
diff --git a/internal/ir/view/testdata/tree-generalization.cameo.dot.golden b/internal/ir/view/testdata/tree-generalization.cameo.dot.golden
new file mode 100644
index 0000000000..fac855de93
--- /dev/null
+++ b/internal/ir/view/testdata/tree-generalization.cameo.dot.golden
@@ -0,0 +1,32 @@
+// view: MicroscopeViews::positioned
+// kind: tree
+// stated: render asTreeDiagram
+// canvas: unit=px w=620 h=240
+// layout: neato -n2
+digraph "MicroscopeViews::positioned" {
+ graph [fontname="Arial", fontcolor="#424242", layout=neato, inputscale=72, dpi=72];
+ node [shape=box, style=filled, fillcolor="#FFCC99:#FFFAD4", gradientangle=0, color="#99795C", fontname="Arial", fontsize=11, fontcolor="#424242", penwidth=1];
+ edge [color="#424242", fontname="Arial", fontsize=9, fontcolor="#424242", penwidth=1, arrowhead=open];
+ "canvas:0" [shape=point, style=invis, width=0, height=0, label="", pos="0,240!", pin=true];
+ "canvas:1" [shape=point, style=invis, width=0, height=0, label="", pos="620,0!", pin=true];
+ subgraph "cluster_frame" {
+ label=<bdd [Block] Detector [ positioned ]>;
+ labeljust=l;
+ labelloc=t;
+ fontsize=11;
+ color="#5B5B59";
+ penwidth=1;
+ margin=8;
+ bb="0,0,620,240";
+ "n0" [label=<«part def»
Detector>, margin=0, pos="315,203.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n1" [label=<«part def»
Sensor>, margin=0, pos="535,203.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n2" [label=<«part def»
Instrument>, margin=0, pos="75,203.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n3" [label=<«part def»
Display System>, margin=0, pos="75,133.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n4" [label=<«part def»
Scanning Electron
Microscope>, margin=0, pos="75,63.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n5" [label=<«part def»
Secondary Electron Detector>, margin=0, pos="240,63.5!", pin=true, width=2.2222222222222223, height=0.4583333333333333, fixedsize=true];
+ "n6" [label=<«part def»
Backscattered Electron
Detector>, margin=0, pos="480,63.5!", pin=true, width=2.2222222222222223, height=0.4583333333333333, fixedsize=true];
+ }
+ "n5" -> "n0" [arrowhead=empty, pos="e,315,187 240,80 240,80 240,130 240,130 240,130 315,130 315,130 315,130 315,177 315,177"];
+ "n6" -> "n0" [arrowhead=empty, pos="e,315,187 440,80 440,80 440,130 440,130 440,130 315,130 315,130 315,130 315,177 315,177"];
+ "n6" -> "n1" [arrowhead=empty, pos="e,535,187 520,80 520,80 520,120 520,120 520,120 535,120 535,120 535,120 535,177 535,177"];
+}
diff --git a/internal/ir/view/testdata/tree-generalization.d2.golden b/internal/ir/view/testdata/tree-generalization.d2.golden
new file mode 100644
index 0000000000..5ba52e9ce8
--- /dev/null
+++ b/internal/ir/view/testdata/tree-generalization.d2.golden
@@ -0,0 +1,27 @@
+# MicroscopeViews::positioned — tree rendering (render asTreeDiagram)
+# not represented: 7 positioned node(s) and 3 route(s) kept as comments; D2 pins no position, the dot form does
+classes: {
+ definition: { style: { fill: white; stroke: "#181818"; stroke-width: 1; font-color: black; font-size: 14 } }
+ specialization: { target-arrowhead: { shape: triangle; style: { filled: false } }; style: { stroke: "#181818"; font-size: 13; font-color: black; stroke-width: 1 } }
+}
+# canvas: unit=px w=620 h=240
+# layout: n0 x=260 y=20 w=110 h=33
+# layout: n1 x=480 y=20 w=110 h=33
+# layout: n2 x=20 y=20 w=110 h=33
+# layout: n3 x=20 y=90 w=110 h=33
+# layout: n4 x=20 y=160 w=110 h=33
+# layout: n5 x=160 y=160 w=160 h=33
+# layout: n6 x=400 y=160 w=160 h=33
+# route: n5->n0 240,160 240,110 315,110 315,53
+# route: n6->n0 440,160 440,110 315,110 315,53
+# route: n6->n1 520,160 520,120 535,120 535,53
+n0: "«part def»\nDetector" { class: definition }
+n1: "«part def»\nSensor" { class: definition }
+n2: "«part def»\nInstrument" { class: definition }
+n3: "«part def»\n'Display System'" { class: definition }
+n4: "«part def»\n'Scanning Electron Microscope'" { class: definition }
+n5: "«part def»\n'Secondary Electron Detector'" { class: definition }
+n6: "«part def»\n'Backscattered Electron Detector'" { class: definition }
+n5 -> n0: { class: specialization }
+n6 -> n0: { class: specialization }
+n6 -> n1: { class: specialization }
diff --git a/internal/ir/view/testdata/tree-generalization.dot.golden b/internal/ir/view/testdata/tree-generalization.dot.golden
new file mode 100644
index 0000000000..8caa58e0f1
--- /dev/null
+++ b/internal/ir/view/testdata/tree-generalization.dot.golden
@@ -0,0 +1,22 @@
+// view: MicroscopeViews::positioned
+// kind: tree
+// stated: render asTreeDiagram
+// canvas: unit=px w=620 h=240
+// layout: neato -n2
+digraph "MicroscopeViews::positioned" {
+ graph [fontname="Helvetica", layout=neato, inputscale=72, dpi=72];
+ node [shape=box, style=filled, fillcolor=white, color="#181818", fontname="Helvetica", fontsize=14, penwidth=0.5];
+ edge [color="#181818", fontname="Helvetica", fontsize=13, penwidth=1];
+ "canvas:0" [shape=point, style=invis, width=0, height=0, label="", pos="0,240!", pin=true];
+ "canvas:1" [shape=point, style=invis, width=0, height=0, label="", pos="620,0!", pin=true];
+ "n0" [label=<«part def»
Detector>, margin=0, pos="315,203.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n1" [label=<«part def»
Sensor>, margin=0, pos="535,203.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n2" [label=<«part def»
Instrument>, margin=0, pos="75,203.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n3" [label=<«part def»
'Display System'>, margin=0, pos="75,133.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n4" [label=<«part def»
'Scanning Electron
Microscope'>, margin=0, pos="75,63.5!", pin=true, width=1.5277777777777777, height=0.4583333333333333, fixedsize=true];
+ "n5" [label=<«part def»
'Secondary Electron Detector'>, margin=0, pos="240,63.5!", pin=true, width=2.2222222222222223, height=0.4583333333333333, fixedsize=true];
+ "n6" [label=<«part def»
'Backscattered Electron
Detector'>, margin=0, pos="480,63.5!", pin=true, width=2.2222222222222223, height=0.4583333333333333, fixedsize=true];
+ "n5" -> "n0" [arrowhead=empty, pos="e,315,187 240,80 240,80 240,130 240,130 240,130 315,130 315,130 315,130 315,177 315,177"];
+ "n6" -> "n0" [arrowhead=empty, pos="e,315,187 440,80 440,80 440,130 440,130 440,130 315,130 315,130 315,130 315,177 315,177"];
+ "n6" -> "n1" [arrowhead=empty, pos="e,535,187 520,80 520,80 520,120 520,120 520,120 535,120 535,120 535,120 535,177 535,177"];
+}
diff --git a/internal/ir/view/testdata/tree-generalization.mermaid.golden b/internal/ir/view/testdata/tree-generalization.mermaid.golden
new file mode 100644
index 0000000000..05ab5df07e
--- /dev/null
+++ b/internal/ir/view/testdata/tree-generalization.mermaid.golden
@@ -0,0 +1,53 @@
+---
+config:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ theme: base
+ themeCSS: ".edgeLabel rect { opacity: 1 !important; } .cluster-label .nodeLabel { text-align: center; }"
+ themeVariables:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ fontSize: "14px"
+ primaryColor: "#FFFFFF"
+ secondaryColor: "#FFFFFF"
+ tertiaryColor: "#FFFFFF"
+ background: "#FFFFFF"
+ primaryBorderColor: "#181818"
+ primaryTextColor: "#000000"
+ lineColor: "#181818"
+ textColor: "#000000"
+ noteBkgColor: "#FEFFDD"
+ noteBorderColor: "#181818"
+ noteTextColor: "#000000"
+ clusterBkg: "#FFFFFF"
+ clusterBorder: "#181818"
+ edgeLabelBackground: "#FFFFFF"
+---
+%% MicroscopeViews::positioned — tree rendering (render asTreeDiagram)
+%% canvas: unit=px w=620 h=240
+%% layout: n0 x=260 y=20 w=110 h=33
+%% layout: n1 x=480 y=20 w=110 h=33
+%% layout: n2 x=20 y=20 w=110 h=33
+%% layout: n3 x=20 y=90 w=110 h=33
+%% layout: n4 x=20 y=160 w=110 h=33
+%% layout: n5 x=160 y=160 w=160 h=33
+%% layout: n6 x=400 y=160 w=160 h=33
+%% route: n5->n0 240,160 240,110 315,110 315,53
+%% route: n6->n0 440,160 440,110 315,110 315,53
+%% route: n6->n1 520,160 520,120 535,120 535,53
+flowchart TD
+ n0["`*«part def»*
+**Detector**`"]
+ n1["`*«part def»*
+**Sensor**`"]
+ n2["`*«part def»*
+**Instrument**`"]
+ n3["`*«part def»*
+**'Display System'**`"]
+ n4["`*«part def»*
+**'Scanning Electron Microscope'**`"]
+ n5["`*«part def»*
+**'Secondary Electron Detector'**`"]
+ n6["`*«part def»*
+**'Backscattered Electron Detector'**`"]
+ n5 -->|"specialization"| n0
+ n6 -->|"specialization"| n0
+ n6 -->|"specialization"| n1
diff --git a/internal/ir/view/testdata/tree-generalization.plantuml.golden b/internal/ir/view/testdata/tree-generalization.plantuml.golden
new file mode 100644
index 0000000000..f180043f61
--- /dev/null
+++ b/internal/ir/view/testdata/tree-generalization.plantuml.golden
@@ -0,0 +1,67 @@
+@startuml
+' MicroscopeViews::positioned — tree rendering (render asTreeDiagram)
+' not represented: 7 positioned node(s) and 3 route(s) kept as comments; PlantUML pins no position, the dot form does
+
+skinparam wrapWidth 300
+hide stereotype
+' canvas: unit=px w=620 h=240
+' layout: n0 x=260 y=20 w=110 h=33
+' layout: n1 x=480 y=20 w=110 h=33
+' layout: n2 x=20 y=20 w=110 h=33
+' layout: n3 x=20 y=90 w=110 h=33
+' layout: n4 x=20 y=160 w=110 h=33
+' layout: n5 x=160 y=160 w=160 h=33
+' layout: n6 x=400 y=160 w=160 h=33
+' route: n5->n0 240,160 240,110 315,110 315,53
+' route: n6->n0 440,160 440,110 315,110 315,53
+' route: n6->n1 520,160 520,120 535,120 535,53
+hide circle
+hide empty members
+class "//«part def»//\n**Detector**" as n0 <>
+class "//«part def»//\n**Sensor**" as n1 <>
+class "//«part def»//\n**Instrument**" as n2 <>
+class "//«part def»//\n**'Display System'**" as n3 <>
+class "//«part def»//\n**'Scanning Electron Microscope'**" as n4 <>
+class "//«part def»//\n**'Secondary Electron Detector'**" as n5 <>
+class "//«part def»//\n**'Backscattered Electron Detector'**" as n6 <>
+n5 --|> n0
+n6 --|> n0
+n6 --|> n1
+@enduml
diff --git a/internal/ir/view/testdata/tree-generalization.sysml b/internal/ir/view/testdata/tree-generalization.sysml
new file mode 100644
index 0000000000..4155d262e1
--- /dev/null
+++ b/internal/ir/view/testdata/tree-generalization.sysml
@@ -0,0 +1,50 @@
+// A block definition diagram migrated from Cameo: boxes 33px high, two
+// subtypes of Detector with the inheritance lines routed as the source drew
+// them, one subtype with two generals, and the same subtype drawn in a second
+// view with a route of its own. The unpositioned view is laid out by the engine.
+package Microscope {
+ part def Detector;
+ part def Sensor;
+ part def Instrument;
+ part def 'Display System';
+ part def 'Scanning Electron Microscope';
+ part def 'Secondary Electron Detector' :> Detector;
+ part def 'Backscattered Electron Detector' :> Detector, Sensor;
+}
+
+package MicroscopeViews {
+ private import Views::*;
+ private import DiagramLayout::*;
+ private import Microscope::*;
+
+ view positioned {
+ expose Microscope::*;
+ @Canvas { unit = "px"; width = 620; height = 240; }
+ metadata Layout about Instrument { x = 20; y = 20; width = 110; height = 33; }
+ metadata Layout about 'Display System' { x = 20; y = 90; width = 110; height = 33; }
+ metadata Layout about 'Scanning Electron Microscope' { x = 20; y = 160; width = 110; height = 33; }
+ metadata Layout about Detector { x = 260; y = 20; width = 110; height = 33; }
+ metadata Layout about Sensor { x = 480; y = 20; width = 110; height = 33; }
+ metadata Layout about 'Secondary Electron Detector' { x = 160; y = 160; width = 160; height = 33; }
+ metadata Layout about 'Backscattered Electron Detector' { x = 400; y = 160; width = 160; height = 33; }
+ metadata Route about 'Secondary Electron Detector' { points = (240, 160, 240, 110, 315, 110, 315, 53); ref :>> general : Detector; }
+ metadata Route about 'Backscattered Electron Detector' { points = (440, 160, 440, 110, 315, 110, 315, 53); ref :>> general : Detector; }
+ metadata Route about 'Backscattered Electron Detector' { points = (520, 160, 520, 120, 535, 120, 535, 53); ref :>> general : Sensor; }
+ render asTreeDiagram;
+ }
+
+ view second {
+ expose Detector;
+ expose 'Backscattered Electron Detector';
+ @Canvas { unit = "px"; width = 300; height = 200; }
+ metadata Layout about Detector { x = 20; y = 20; width = 110; height = 33; }
+ metadata Layout about 'Backscattered Electron Detector' { x = 120; y = 140; width = 160; height = 33; }
+ metadata Route about 'Backscattered Electron Detector' { points = (200, 140, 200, 90, 75, 90, 75, 53); ref :>> general : Detector; }
+ render asTreeDiagram;
+ }
+
+ view unpositioned {
+ expose Microscope::*;
+ render asTreeDiagram;
+ }
+}
diff --git a/internal/ir/view/testdata/tree-generalization.text.golden b/internal/ir/view/testdata/tree-generalization.text.golden
new file mode 100644
index 0000000000..8bde76d22f
--- /dev/null
+++ b/internal/ir/view/testdata/tree-generalization.text.golden
@@ -0,0 +1,16 @@
+MicroscopeViews::positioned - tree rendering (render asTreeDiagram)
+
+canvas size 620×240 in px
+
+part def Detector at (260, 20) size 110×33
+part def Sensor at (480, 20) size 110×33
+part def Instrument at (20, 20) size 110×33
+part def 'Display System' at (20, 90) size 110×33
+part def 'Scanning Electron Microscope' at (20, 160) size 110×33
+part def 'Secondary Electron Detector' at (160, 160) size 160×33
+part def 'Backscattered Electron Detector' at (400, 160) size 160×33
+
+relationships:
+ 'Secondary Electron Detector' --|> Detector via (240, 160) (240, 110) (315, 110) (315, 53)
+ 'Backscattered Electron Detector' --|> Detector via (440, 160) (440, 110) (315, 110) (315, 53)
+ 'Backscattered Electron Detector' --|> Sensor via (520, 160) (520, 120) (535, 120) (535, 53)
diff --git a/internal/ir/view/tree_edges.go b/internal/ir/view/tree_edges.go
index fedd585ee0..65e8f34cd8 100644
--- a/internal/ir/view/tree_edges.go
+++ b/internal/ir/view/tree_edges.go
@@ -244,7 +244,8 @@ func (g *treeGraph) edgesOf(node *Node, site treeSite) {
continue
}
if to := g.node(target); to != nil && to != node && !g.nested(node, to) {
- g.add(Edge{From: node.ID, To: to.ID, Kind: kind, Label: label, Origin: nodeOrigin(sym.DocName, rel)}, nil)
+ g.add(Edge{From: node.ID, To: to.ID, Kind: kind, Label: label, Origin: nodeOrigin(sym.DocName, rel),
+ Route: g.r.specializationRouteOf(site.view, sym, target, g.out)}, nil)
}
}
for _, child := range node.Children {
diff --git a/internal/ir/view/tree_generalization_test.go b/internal/ir/view/tree_generalization_test.go
new file mode 100644
index 0000000000..76910ab6f6
--- /dev/null
+++ b/internal/ir/view/tree_generalization_test.go
@@ -0,0 +1,91 @@
+package view
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+// TestGoldenTreeGeneralization locks every graphical form of a positioned tree
+// whose specialization edges follow Routes naming their generals, in Cameo-sized
+// boxes that keep their keyword line beside a wrapped name.
+func TestGoldenTreeGeneralization(t *testing.T) {
+ rendering := render(t, "tree-generalization.sysml", "MicroscopeViews::positioned")
+ for _, form := range []Form{FormDot, FormMermaid, FormPlantUML, FormD2, FormText} {
+ out, err := rendering.Write(form)
+ if err != nil {
+ t.Fatalf("write %s: %v", form, err)
+ }
+ checkGolden(t, filepath.Join("testdata", "tree-generalization."+string(form)+".golden"), out)
+ }
+ cameo, err := rendering.DOTWith(Options{Style: StyleCameo})
+ if err != nil {
+ t.Fatalf("DOTWith(cameo): %v", err)
+ }
+ checkGolden(t, filepath.Join("testdata", "tree-generalization.cameo.dot.golden"), cameo)
+ for _, view := range []string{"second", "unpositioned"} {
+ dot, err := render(t, "tree-generalization.sysml", "MicroscopeViews::"+view).Write(FormDot)
+ if err != nil {
+ t.Fatalf("write %s: %v", view, err)
+ }
+ checkGolden(t, filepath.Join("testdata", "tree-generalization-"+view+".dot.golden"), dot)
+ }
+}
+
+// TestTreeSpecializationRoutes checks that a Route about a specific element
+// naming a general steers the edge to that general alone: each general of a
+// subtype with two gets its own line, the same subtype follows a different
+// Route in another view, and the unpositioned tree routes nothing.
+func TestTreeSpecializationRoutes(t *testing.T) {
+ routes := func(view string) map[string][]Point {
+ rendering := render(t, "tree-generalization.sysml", "MicroscopeViews::"+view)
+ names := map[string]string{}
+ for _, root := range rendering.Roots {
+ names[root.ID] = root.Name
+ }
+ out := map[string][]Point{}
+ for _, edge := range rendering.Edges {
+ if edge.Kind == EdgeSpecialization {
+ out[names[edge.From]+" -> "+names[edge.To]] = edge.Route
+ }
+ }
+ return out
+ }
+ positioned := routes("positioned")
+ want := map[string][]Point{
+ "Microscope::'Secondary Electron Detector' -> Microscope::Detector": {{240, 160}, {240, 110}, {315, 110}, {315, 53}},
+ "Microscope::'Backscattered Electron Detector' -> Microscope::Detector": {{440, 160}, {440, 110}, {315, 110}, {315, 53}},
+ "Microscope::'Backscattered Electron Detector' -> Microscope::Sensor": {{520, 160}, {520, 120}, {535, 120}, {535, 53}},
+ }
+ if len(positioned) != len(want) {
+ t.Fatalf("positioned draws %d specialization edges, want %d: %v", len(positioned), len(want), positioned)
+ }
+ for edge, points := range want {
+ if got := positioned[edge]; !samePoints(got, points) {
+ t.Errorf("positioned route of %s = %v, want %v", edge, got, points)
+ }
+ }
+ second := routes("second")
+ if got := second["Microscope::'Backscattered Electron Detector' -> Microscope::Detector"]; !samePoints(got, []Point{{200, 140}, {200, 90}, {75, 90}, {75, 53}}) {
+ t.Errorf("second route = %v, want the view's own Route", got)
+ }
+ if len(second) != 1 {
+ t.Errorf("second draws %d specialization edges, want the one between its two exposed elements", len(second))
+ }
+ for edge, points := range routes("unpositioned") {
+ if len(points) != 0 {
+ t.Errorf("unpositioned routes %s through %v, want nothing", edge, points)
+ }
+ }
+}
+
+func samePoints(got, want []Point) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ for i := range got {
+ if got[i] != want[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/semantic/semantics/layout.go b/internal/semantic/semantics/layout.go
index a0543b4463..bb3f37d825 100644
--- a/internal/semantic/semantics/layout.go
+++ b/internal/semantic/semantics/layout.go
@@ -114,6 +114,10 @@ type LayoutSite struct {
// Via is the namespace an `about` clause named the element through, when
// qualified: `about Acquire::start` positions the start Acquire inherits.
Via *symbols.Symbol
+ // General is the element a Route names with `ref :>> general : G;`: the
+ // Route steers the edge drawn from the annotated element to G for its
+ // specialization, not an edge the element declares itself. Nil otherwise.
+ General *symbols.Symbol
// Exactly one of Layout, Route, Canvas, Style, Note and Picture is set when
// the annotation reads; all are nil when a binding it needs has a Problem.
Layout *Layout
@@ -346,9 +350,29 @@ func (m *Model) memberReferent(scope *symbols.Scope, member *symbols.Symbol, nam
// RouteOf resolves the Route of the element an edge is declared as — a
// connector, flow, transition or succession — as drawn in view, as LayoutOf
-// resolves a Layout.
+// resolves a Layout. A Route naming a general steers another edge and is
+// passed over.
func (m *Model) RouteOf(view, elem *symbols.Symbol) (*LayoutSite, bool) {
- return m.resolveSite(view, elem, RouteFQN)
+ var sites []*LayoutSite
+ for _, site := range m.LayoutSitesOf(elem) {
+ if site.TypeFQN == RouteFQN && site.General == nil {
+ sites = append(sites, site)
+ }
+ }
+ return pickSite(view, sites)
+}
+
+// SpecializationRouteOf resolves the Route of the edge drawn from elem to
+// general for the specialization, typing or subsetting elem states, as drawn in
+// view: a Route about elem naming general, picked as RouteOf picks.
+func (m *Model) SpecializationRouteOf(view, elem, general *symbols.Symbol) (*LayoutSite, bool) {
+ var sites []*LayoutSite
+ for _, site := range m.LayoutSitesOf(elem) {
+ if site.TypeFQN == RouteFQN && site.General != nil && sameElement(site.General, general) {
+ sites = append(sites, site)
+ }
+ }
+ return pickSite(view, sites)
}
// StyleOf resolves the Style of elem as drawn in view, as LayoutOf resolves a
@@ -548,11 +572,12 @@ func (m *Model) readLayout(site *LayoutSite, bindings []MetadataBinding) {
}
// readRoute reads a Route body: points is a sequence of reals, x and y
-// alternating, so an odd count leaves a point without its y.
+// alternating, so an odd count leaves a point without its y; general, when
+// typed, is the element the steered specialization edge is drawn to.
func (m *Model) readRoute(site *LayoutSite, bindings []MetadataBinding) {
var values []float64
var elements []ast.Node
- ok := true
+ ok := m.readGeneral(site)
for _, b := range bindings {
if b.Feature != "points" || b.Value == nil {
continue
@@ -582,6 +607,43 @@ func (m *Model) readRoute(site *LayoutSite, bindings []MetadataBinding) {
site.Route = route
}
+// readGeneral reads the general a Route body names, `ref :>> general : G;`,
+// into site.General; false when G resolves to no element.
+func (m *Model) readGeneral(site *LayoutSite) bool {
+ for _, member := range metadataBody(site.Node) {
+ usage := metadataBodyFeature(member)
+ if usage == nil || redefinedFeatureName(usage) != "general" {
+ continue
+ }
+ typeRef := typingTargetName(usage)
+ if typeRef == nil {
+ continue
+ }
+ if general := m.resolveMetadataType(valueScope(site.Scope, site.Node), typeRef); general != nil {
+ site.General = general
+ return true
+ }
+ site.Problems = append(site.Problems, LayoutProblem{Node: typeRef,
+ Message: fmt.Sprintf("Route names general %s, which resolves to no element", typeRef.Text())})
+ return false
+ }
+ return true
+}
+
+// typingTargetName is the name a usage's typing clause states, nil when it
+// states none.
+func typingTargetName(usage *ast.Usage) *ast.QualifiedName {
+ for _, rel := range usage.Relationships {
+ if rel == nil || rel.Kind != ast.RelTyping {
+ continue
+ }
+ if qn := ast.AsQualifiedName(rel.Target); qn != nil && len(qn.Parts) > 0 {
+ return qn
+ }
+ }
+ return nil
+}
+
// readCanvas reads a Canvas body: an optional unit string and an optional
// width and height pair.
func (m *Model) readCanvas(site *LayoutSite, bindings []MetadataBinding) {
diff --git a/internal/translate/migrate/diagrams.go b/internal/translate/migrate/diagrams.go
index 08b842cbb3..9cdae3b314 100644
--- a/internal/translate/migrate/diagrams.go
+++ b/internal/translate/migrate/diagrams.go
@@ -394,6 +394,15 @@ func (m *migration) diagramLayoutAttributeForHost(prefix, definition, name, valu
return diagramLayoutAttributeText(prefix, definition, name, value, x.shadows(name) || m.shadows(scopeChain(host), name))
}
+// diagramLayoutFeatureForHost names a feature redefined in an annotation body,
+// qualified when the view or its containing scopes shadow its name.
+func (m *migration) diagramLayoutFeatureForHost(prefix, definition, name string, host *sysmlv1.Element, x exposures) string {
+ if x.shadows(name) || m.shadows(scopeChain(host), name) {
+ return prefix + definition + "::" + name
+ }
+ return name
+}
+
func diagramLayoutAttributeText(prefix, definition, name, value string, shadowed bool) string {
if shadowed {
return ":>> " + prefix + definition + "::" + name + " = " + value
@@ -1368,15 +1377,15 @@ func (p *layoutPlan) routes(rec *mtip.Diagram) []string {
kind := routeKindName(c.Type, el)
var refs []string
var points [][]float64
- why := ""
- if refs, why = m.routeTarget(el, v.host, p.form); why == "" {
- if !m.draws(p.x, p.form, v.host, el, refs[0]) {
+ general, why := "", ""
+ if refs, general, why = m.routeTarget(el, v.host, p.form); why == "" {
+ if !m.draws(p.x, p.form, v.host, el, refs[0]) || general != "" && !p.x.exposed(general) {
why = routeNotExposed
}
refs, points = m.routeLines(el, p.form, refs, m.linePoints(el, c, boxes))
}
ref := strings.Join(refs, ", ")
- if why == "" && pinned[ref] {
+ if why == "" && pinned[ref+" :> "+general] {
p.refOf[c.ID] = ref
why = routeDuplicate
}
@@ -1386,20 +1395,21 @@ func (p *layoutPlan) routes(rec *mtip.Diagram) []string {
m.routeKinds.add(kind, why)
continue
}
- pinned[ref] = true
+ pinned[ref+" :> "+general] = true
p.refOf[c.ID] = ref
s.RoutesWritten++
p.reasons[routeWritten]++
m.routeKinds.add(kind, routeWritten)
for i, r := range refs {
- routes = append(routes, p.route(r, points[i]))
+ routes = append(routes, p.route(r, general, points[i]))
}
}
return routes
}
-// route writes a Route pinning points, x then y per point, to the member ref names.
-func (p *layoutPlan) route(ref string, points []float64) string {
+// route writes a Route pinning points, x then y per point, to the member ref
+// names; a general names the element a specialization edge is steered to.
+func (p *layoutPlan) route(ref, general string, points []float64) string {
var b strings.Builder
b.WriteString("metadata " + p.prefix + "Route about " + ref + " { ")
b.WriteString(p.m.diagramLayoutAttributeForHost(p.prefix, "Route", "points", "(", p.v.host, p.x))
@@ -1414,7 +1424,11 @@ func (p *layoutPlan) route(ref string, points []float64) string {
p.grow(p.maxX, n)
}
}
- b.WriteString("); }")
+ b.WriteString(");")
+ if general != "" {
+ b.WriteString(" ref :>> " + p.m.diagramLayoutFeatureForHost(p.prefix, "Route", "general", p.v.host, p.x) + " : " + general + ";")
+ }
+ b.WriteString(" }")
return b.String()
}
@@ -1469,6 +1483,9 @@ func (m *migration) lineEnds(el *sysmlv1.Element) (first, second *sysmlv1.Elemen
if prop := m.backedProperty(el); prop != nil {
return prop.Parent, m.model.Ref(prop, "type")
}
+ if el.Type == "Generalization" {
+ return el.Parent, m.model.Ref(el, "general")
+ }
if !m.associationDef(el) {
return nil, nil
}
@@ -1524,7 +1541,7 @@ func (m *migration) drawnRef(x exposures, f viewForm, host *sysmlv1.Element, id
if ref := m.inheritedNodeRef(el, host); ref != "" && m.places(x, f, el, ref) {
return ref
}
- refs, why := m.routeTarget(el, host, f)
+ refs, _, why := m.routeTarget(el, host, f)
if why != "" || !m.draws(x, f, host, el, refs[0]) {
return ""
}
diff --git a/internal/translate/migrate/edges.go b/internal/translate/migrate/edges.go
index b86a0cf6f0..5425198042 100644
--- a/internal/translate/migrate/edges.go
+++ b/internal/translate/migrate/edges.go
@@ -414,49 +414,74 @@ func routeKindName(exported string, el *sysmlv1.Element) string {
}
// routeTarget is every member a route of el is pinned to, as scope's view of
-// form f exposes them; or nil and the reason there is none.
-func (m *migration) routeTarget(el, scope *sysmlv1.Element, f viewForm) ([]string, string) {
+// form f exposes them; or nil and the reason there is none. A generalization's
+// route is pinned to the specific, naming the general the `:>` is drawn to.
+func (m *migration) routeTarget(el, scope *sysmlv1.Element, f viewForm) (refs []string, general, why string) {
if c := m.connOf(el); f.useCases && c != nil {
- return m.connRefs(c.of, scope)[:1], ""
+ return m.connRefs(c.of, scope)[:1], "", ""
}
if m.associationDef(el) {
if !f.drawsEdge("connection def") || !m.associationLine(el) {
- return nil, routeNotDrawn
+ return nil, "", routeNotDrawn
}
- return []string{m.exposure(el, scope)}, ""
+ return []string{m.exposure(el, scope)}, "", ""
+ }
+ if el.Type == "Generalization" {
+ return m.generalizationRouteTarget(el, scope, f)
}
if prop := m.backedProperty(el); prop != nil {
// The line is the backed attribute's typing edge, drawn from its owner's
// box to its type's; the route pins that edge to the attribute.
if !f.drawsEdge("attribute") {
- return nil, routeNotDrawn
+ return nil, "", routeNotDrawn
}
if m.writtenHidden(prop) {
// A private attribute is out of the view's reach.
- return nil, routeNotExposed
+ return nil, "", routeNotExposed
}
- return []string{m.exposure(prop, scope)}, ""
+ return []string{m.exposure(prop, scope)}, "", ""
}
if nameableEdge(el) {
em, ok := m.edgeMembers[el]
switch {
case !ok:
- return nil, routeNotWritten
+ return nil, "", routeNotWritten
case em.none:
- return nil, routeNoMember
+ return nil, "", routeNoMember
case em.name == "":
- return nil, routeUnnamed
+ return nil, "", routeUnnamed
case !f.drawsEdge(em.keyword):
- return nil, routeNotDrawn
+ return nil, "", routeNotDrawn
case !m.reaches(em.owner):
- return nil, routeNotExposed
+ return nil, "", routeNotExposed
}
- return m.routeRefs(el, scope, f), ""
+ return m.routeRefs(el, scope, f), "", ""
}
if m.exposure(el, scope) != "" {
- return nil, routeNotDrawn
+ return nil, "", routeNotDrawn
+ }
+ return nil, "", routeNoMember
+}
+
+// generalizationRouteTarget is routeTarget for generalization el: the specific
+// its `:>` clause is written on, and the general the tree draws the edge to.
+func (m *migration) generalizationRouteTarget(el, scope *sysmlv1.Element, f viewForm) (refs []string, general, why string) {
+ specific, target := el.Parent, m.model.Ref(el, "general")
+ if specific == nil || target == nil {
+ return nil, "", routeNotWritten
+ }
+ cat, _ := m.classify(specific)
+ if ref, _ := m.general(specific, el, cat); ref == "" {
+ return nil, "", routeNotWritten
+ }
+ if !f.drawsEdge("specialization") {
+ return nil, "", routeNotDrawn
+ }
+ ref, generalRef := m.exposure(specific, scope), m.exposure(target, scope)
+ if ref == "" || generalRef == "" {
+ return nil, "", routeNotExposed
}
- return nil, routeNoMember
+ return []string{ref}, generalRef, ""
}
// associationDef reports whether e is an association written as a connection
@@ -603,7 +628,7 @@ func (f viewForm) drawsEdge(keyword string) bool {
case interconnectionRendering:
return keyword == "connection" || keyword == "binding" || keyword == "flow"
case treeRendering:
- return keyword == "connection def" || keyword == "attribute"
+ return keyword == "connection def" || keyword == "attribute" || keyword == "specialization"
}
return false
}
diff --git a/internal/workspace/libs/stdlib.snapshot b/internal/workspace/libs/stdlib.snapshot
index 19c9cc6357..ea50d9ad80 100644
Binary files a/internal/workspace/libs/stdlib.snapshot and b/internal/workspace/libs/stdlib.snapshot differ
diff --git a/internal/workspace/libs/stdlib/OpenSysML Libraries/DiagramLayout.sysml b/internal/workspace/libs/stdlib/OpenSysML Libraries/DiagramLayout.sysml
index fdf0b1a32f..06b96419b3 100644
--- a/internal/workspace/libs/stdlib/OpenSysML Libraries/DiagramLayout.sysml
+++ b/internal/workspace/libs/stdlib/OpenSysML Libraries/DiagramLayout.sysml
@@ -17,8 +17,13 @@ library package DiagramLayout {
metadata def Route {
doc /* The waypoints a connection, transition, succession or flow is
- * drawn through, in order, flattened as x0, y0, x1, y1, ... */
+ * drawn through, in order, flattened as x0, y0, x1, y1, ...
+ * A specialization has no element of its own to annotate: its
+ * Route is stated about the specific element and names the
+ * general it is drawn to, `ref :>> general : Sup;`, one Route
+ * per general. */
attribute points : ScalarValues::Real[0..*] ordered nonunique;
+ ref general : Base::Anything[0..1];
}
metadata def Canvas {
diff --git a/internal/workspace/libs/stdlib/OpenSysML Libraries/engine-contract.json b/internal/workspace/libs/stdlib/OpenSysML Libraries/engine-contract.json
index b0c890843d..03e64579d0 100644
--- a/internal/workspace/libs/stdlib/OpenSysML Libraries/engine-contract.json
+++ b/internal/workspace/libs/stdlib/OpenSysML Libraries/engine-contract.json
@@ -139,13 +139,21 @@
"name": "DiagramLayout::Route",
"kind": "metadata",
"attributes": [
- "points"
+ "points",
+ "general"
],
"migratorEmitted": true,
"consumers": [
"internal/semantic/semantics/layout.go"
]
},
+ {
+ "name": "DiagramLayout::Route::general",
+ "kind": "element",
+ "consumers": [
+ "internal/semantic/semantics/layout.go"
+ ]
+ },
{
"name": "DiagramLayout::Route::points",
"kind": "element",
diff --git a/scripts/extension-libraries-pin.sh b/scripts/extension-libraries-pin.sh
index 81137a7afd..be34f8bf95 100755
--- a/scripts/extension-libraries-pin.sh
+++ b/scripts/extension-libraries-pin.sh
@@ -8,4 +8,4 @@
# verifies, because a ref is mutable. Change them together.
EXTENSIONS_REPO="${EXTENSIONS_REPO:-https://github.com/Open-MBEE/OpenSysML-Extensions-Library.git}"
EXTENSIONS_REF="${EXTENSIONS_REF:-main}"
-EXTENSIONS_COMMIT="${EXTENSIONS_COMMIT:-73bed354e7bed2829d3e4c779ab77dd1b3aa5aaf}"
+EXTENSIONS_COMMIT="${EXTENSIONS_COMMIT:-b6af2597941045fe32d8c08f8d86d150cb5418a8}"
diff --git a/tests/migrate/layout_test.go b/tests/migrate/layout_test.go
index c73689cad8..a1a709687a 100644
--- a/tests/migrate/layout_test.go
+++ b/tests/migrate/layout_test.go
@@ -246,7 +246,7 @@ func TestGoldenEdgeLayout(t *testing.T) {
if l == nil {
t.Fatal("no layout summary")
}
- if l.Routes != 24 || l.RoutesWritten != 12 || l.RoutesUnexposed != 11 || l.RoutesDangling != 1 {
+ if l.Routes != 24 || l.RoutesWritten != 13 || l.RoutesUnexposed != 10 || l.RoutesDangling != 1 {
t.Errorf("routes: %+v", l)
}
// An edge of the graph the activity's rendering does not draw is exposed, not swallowed.
@@ -292,7 +292,7 @@ func TestGoldenEdgeLayout(t *testing.T) {
{Kind: "ControlFlow", Reason: "unnamed", Count: 1},
{Kind: "ControlFlow", Reason: "written", Count: 2},
{Kind: "Dependency", Reason: "not drawn", Count: 2},
- {Kind: "Generalization", Reason: "no v2 member", Count: 1},
+ {Kind: "Generalization", Reason: "written", Count: 1},
{Kind: "Include", Reason: "written", Count: 1},
{Kind: "ObjectFlow", Reason: "not drawn", Count: 1},
{Kind: "ObjectFlow", Reason: "written", Count: 1},
diff --git a/tests/migrate/testdata/xmi/diagram_edges.layout.golden.report.txt b/tests/migrate/testdata/xmi/diagram_edges.layout.golden.report.txt
index 5aab2da222..62f7a9cc4f 100644
--- a/tests/migrate/testdata/xmi/diagram_edges.layout.golden.report.txt
+++ b/tests/migrate/testdata/xmi/diagram_edges.layout.golden.report.txt
@@ -1,11 +1,11 @@
# SysML v1 to v2 migration report: diagram_edges.xmi
# exported by Example UML Tool
-# migrated 83 element(s): 73 mapped, 10 approximated, 0 unmapped (0 skipped as profile, library or notation-only content, 0 as model elements nothing refers to); laid out 6 of 7 diagrams from diagram_edges.layout.xml: 20 elements positioned, 12 connectors routed, 0 styled, 0 notes
+# migrated 83 element(s): 73 mapped, 10 approximated, 0 unmapped (0 skipped as profile, library or notation-only content, 0 as model elements nothing refers to); laid out 6 of 7 diagrams from diagram_edges.layout.xml: 20 elements positioned, 13 connectors routed, 0 styled, 0 notes
## layout
# source: diagram_edges.layout.xml (2022x v1.0.0, cameo 2022x Refresh2, exported 2025-06-12T21:38:50.779458500Z)
# 6 diagram records: 6 joined, 0 matching no diagram; 0 views laid out from their own symbol stream, 1 without layout
-# placements: 20 of 22 written (2 not exposed, 0 resolving to no element); routes: 12 of 24 written (11 not pinned, 1 resolving to no element); malformed: 0
+# placements: 20 of 22 written (2 not exposed, 0 resolving to no element); routes: 13 of 24 written (10 not pinned, 1 resolving to no element); malformed: 0
# styles: 0 of 0 written; notes: 0 written (0 anchored, 0 freed of an anchor the view does not lay out)
# routes of Association: 1 written
# routes of AssociationClass: 1 not drawn
@@ -15,7 +15,7 @@
# routes of ControlFlow: 1 unnamed
# routes of ControlFlow: 2 written
# routes of Dependency: 2 not drawn
-# routes of Generalization: 1 no v2 member
+# routes of Generalization: 1 written
# routes of Include: 1 written
# routes of ObjectFlow: 1 not drawn
# routes of ObjectFlow: 1 written
@@ -33,7 +33,7 @@ OpaqueBehavior Behavior::Modes::::Running::spin _st_running_do -> Model:
Association Structure:: _assoc_wheel -> Model::Structure::WheelToVehicle (the anonymous Association is written as connection def WheelToVehicle; its one usage is the connection wheel to vehicle joining wheel and vehicle)
Dependency Structure:: _dep_pairs (written as 2 relationships, one per client–supplier pair)
Property Structure::Pairing::tag _pair_tag -> Model::Structure::Pairing::tag (the untyped property is written as an attribute)
-Diagram Structure::Structure _diag_bdd -> Model::Structure::Structure (a SysML Block Definition Diagram written as a view rendered asTreeDiagram; 1 of 10 shown elements are not written and not exposed; laid out from diagram_edges.layout.xml: 4 of 4 shown elements positioned, 1 of 5 connectors routed (3 not drawn, 1 no v2 member))
+Diagram Structure::Structure _diag_bdd -> Model::Structure::Structure (a SysML Block Definition Diagram written as a view rendered asTreeDiagram; 1 of 10 shown elements are not written and not exposed; laid out from diagram_edges.layout.xml: 4 of 4 shown elements positioned, 2 of 5 connectors routed (3 not drawn))
Diagram Structure::Vehicle::Drive::Driving _diag_act -> Model::Structure::Vehicle::Drive::Driving (a SysML Activity Diagram written as a view rendered asInterconnectionDiagram of ActionFlowView; the view exposes action Model::Structure::Vehicle::Drive, whose graph the rendering draws with the 7 shown nodes and edges of it; 1 of 9 shown elements are not written and not exposed; laid out from diagram_edges.layout.xml: 4 of 4 shown elements positioned, 3 of 5 connectors routed (1 not drawn, 1 unnamed))
Diagram Structure::Vehicle::Drive::Driving Sketch _diag_act_empty -> Model::Structure::Vehicle::Drive::'Driving Sketch' (a SysML Activity Diagram written as a view rendered asTextualNotation; the diagram shows nothing; the view exposes nothing)
diff --git a/tests/migrate/testdata/xmi/diagram_edges.layout.golden.sysml b/tests/migrate/testdata/xmi/diagram_edges.layout.golden.sysml
index ccda9cb2a3..6818ed5ba3 100644
--- a/tests/migrate/testdata/xmi/diagram_edges.layout.golden.sysml
+++ b/tests/migrate/testdata/xmi/diagram_edges.layout.golden.sysml
@@ -109,6 +109,7 @@ package Model {
metadata DiagramLayout::Layout about Vehicle { x = 10; y = 160; width = 100; height = 80; }
metadata DiagramLayout::Layout about Engine { x = 120; y = 160; width = 100; height = 80; }
metadata DiagramLayout::Layout about Wheel { x = 230; y = 160; width = 100; height = 80; }
+ metadata DiagramLayout::Route about Engine { points = (170, 160, 170, 80); ref :>> general : Component; }
metadata DiagramLayout::Route about WheelToVehicle { points = (260, 220, 210, 260, 160, 220); }
render Views::asTreeDiagram;
}