From 10c69366add00ce287e2c176092447d3cd6ae203 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:25:59 +0000 Subject: [PATCH 01/17] fix(view): leave layout annotations and render members out of renderings A view's DiagramLayout annotations and its render members describe the picture, not the model; the shared member walk now skips them so every graphical form omits them, while ordinary metadata usages stay shown. Co-Authored-By: jason.han --- internal/ir/view/bookkeeping_test.go | 64 +++++++++++++++++++++ internal/ir/view/interconnection.go | 2 +- internal/ir/view/place.go | 2 +- internal/ir/view/sequence.go | 12 ++-- internal/ir/view/table.go | 2 +- internal/ir/view/testdata/bookkeeping.sysml | 38 ++++++++++++ internal/ir/view/tree.go | 24 ++++++-- internal/semantic/semantics/layout.go | 27 +++++++++ 8 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 internal/ir/view/bookkeeping_test.go create mode 100644 internal/ir/view/testdata/bookkeeping.sysml diff --git a/internal/ir/view/bookkeeping_test.go b/internal/ir/view/bookkeeping_test.go new file mode 100644 index 000000000..6ac718f6f --- /dev/null +++ b/internal/ir/view/bookkeeping_test.go @@ -0,0 +1,64 @@ +package view + +import ( + "maps" + "slices" + "testing" +) + +// allNodes walks a rendering's nodes in depth-first order. +func allNodes(roots []*Node) []*Node { + var out []*Node + for _, node := range roots { + out = append(out, node) + out = append(out, allNodes(node.Children)...) + } + return out +} + +// A tree shows the model, not the picture of it: a view's `render` member and +// every DiagramLayout annotation are left out, whether stated inline on the +// element, about a member, or in a view's body. Ordinary metadata stays. +func TestTreeLeavesOutLayoutAndRenderMembers(t *testing.T) { + rendering := render(t, "bookkeeping.sysml", "BudgetViews::budgetView") + names := nodeNames(rendering.Roots) + for _, want := range []string{"Mirror", "errorReq", "errorCBE", "Approved", "details", "asTree"} { + if !names[want] { + t.Errorf("node %q missing; nodes: %v", want, slices.Sorted(maps.Keys(names))) + } + } + for _, node := range allNodes(rendering.Roots) { + switch { + case node.Kind == "metadata" && node.Type != "Approved": + t.Errorf("layout annotation drawn as node %q : %q", node.Name, node.Type) + case node.Kind == "render" || node.Name == "asTreeDiagram": + t.Errorf("render member drawn as node %q", node.Name) + } + } + for _, name := range []string{"x", "y", "width", "height", "unit", "placed"} { + if names[name] { + t.Errorf("layout bookkeeping %q drawn as a node", name) + } + } + details := findNode(t, rendering.Roots, "details") + if len(details.Children) != 0 { + t.Errorf("view details has children %v, want none", nodeNames(details.Children)) + } +} + +// What a tree does not draw, no Layout can position. +func TestLayoutAnnotationsAreNotDrawn(t *testing.T) { + r, idx := loadFixtures(t, "bookkeeping.sysml") + for _, fqn := range []string{"Budget::Mirror::placed", "Budget::Mirror::details::asTreeDiagram"} { + syms := idx.LookupQualified(fqn) + if len(syms) == 0 { + continue + } + if node, _ := r.draws(KindTree, syms[0]); node { + t.Errorf("%s is drawn by a tree rendering", fqn) + } + } + if node, _ := r.draws(KindTree, lookup(t, idx, "Budget::asTree")); !node { + t.Error("Budget::asTree is a rendering usage outside a view; a tree draws it") + } +} diff --git a/internal/ir/view/interconnection.go b/internal/ir/view/interconnection.go index 83eba9c33..b1263d0ec 100644 --- a/internal/ir/view/interconnection.go +++ b/internal/ir/view/interconnection.go @@ -71,7 +71,7 @@ func (w *featureWalk) featureNode(sym *symbols.Symbol, seen map[*symbols.Symbol] return node } seen[sym] = true - for _, member := range containedMembers(sym) { + for _, member := range r.containedMembers(sym) { switch { case r.drawsConnector(member): w.connectors = append(w.connectors, member) diff --git a/internal/ir/view/place.go b/internal/ir/view/place.go index 3692d2fd8..ad979710e 100644 --- a/internal/ir/view/place.go +++ b/internal/ir/view/place.go @@ -53,7 +53,7 @@ func (r *Renderer) draws(kind Kind, sym *symbols.Symbol) (node, edge bool) { } switch kind { case KindTree: - return containedKind(sym), false + return r.contentKind(sym), false case KindInterconnection: return featureLike(sym), r.drawsConnector(sym) case KindState: diff --git a/internal/ir/view/sequence.go b/internal/ir/view/sequence.go index 24d59c794..e976a6c01 100644 --- a/internal/ir/view/sequence.go +++ b/internal/ir/view/sequence.go @@ -29,7 +29,7 @@ func (r *Renderer) renderSequence(exposed []*symbols.Symbol, out *Rendering) { case r.model.IsConnectorUsage(elem): stated.addConnector(elem) case occurrenceContainer(elem), lifelineLike(elem): - for _, participant := range lifelinesOf(elem) { + for _, participant := range r.lifelinesOf(elem) { if lifelines[participant] != nil { continue } @@ -62,10 +62,10 @@ func (r *Renderer) renderSequence(exposed []*symbols.Symbol, out *Rendering) { // an interaction declares in its body, else the element itself when it declares // none. The container is not a lifeline of its own interaction, and neither is // behavior it performs, which a sequence rendering shows as messages instead. -func lifelinesOf(elem *symbols.Symbol) []*symbols.Symbol { +func (r *Renderer) lifelinesOf(elem *symbols.Symbol) []*symbols.Symbol { if occurrenceContainer(elem) { var participants []*symbols.Symbol - for _, member := range containedMembers(elem) { + for _, member := range r.containedMembers(elem) { if lifelineLike(member) && !behaviorLike(member) { participants = append(participants, member) } @@ -137,7 +137,7 @@ func (r *Renderer) collectInteractions(sym *symbols.Symbol, into *interactions, } visited[sym] = true r.addSuccessionEdges(sym, into) - for _, member := range containedMembers(sym) { + for _, member := range r.containedMembers(sym) { switch { case isFlowUsage(member): into.addFlow(member) @@ -164,7 +164,7 @@ func (r *Renderer) statesFlow(sym *symbols.Symbol, depth int) bool { if sym == nil || depth >= maxTreeDepth { return false } - for _, member := range containedMembers(sym) { + for _, member := range r.containedMembers(sym) { if isFlowUsage(member) || r.statesFlow(member, depth+1) { return true } @@ -218,7 +218,7 @@ func (r *Renderer) edgeEnd(owner *symbols.Symbol, name *ast.QualifiedName, membe } return nil } - for _, candidate := range containedMembers(owner) { + for _, candidate := range r.containedMembers(owner) { if member != nil && candidate.Decl == member { return candidate } diff --git a/internal/ir/view/table.go b/internal/ir/view/table.go index 921e94b70..2d6c333f5 100644 --- a/internal/ir/view/table.go +++ b/internal/ir/view/table.go @@ -47,7 +47,7 @@ func (r *Renderer) memberRowsOf(sym *symbols.Symbol, name string, seen map[*symb return } seen[sym] = true - for _, member := range containedMembers(sym) { + for _, member := range r.containedMembers(sym) { r.memberRows(member, name, seen, depth+1, out) } } diff --git a/internal/ir/view/testdata/bookkeeping.sysml b/internal/ir/view/testdata/bookkeeping.sysml new file mode 100644 index 000000000..bfd8aef34 --- /dev/null +++ b/internal/ir/view/testdata/bookkeeping.sysml @@ -0,0 +1,38 @@ +package Budget { + private import DiagramLayout::*; + private import Views::*; + + metadata def Approved { + attribute by : ScalarValues::String; + } + + part def Mirror { + attribute errorReq : ScalarValues::Real; + attribute errorCBE : ScalarValues::Real; + // A user annotation is model content the tree shows. + metadata Approved about errorCBE { by = "review"; } + // Layout stated inline is about a picture, not the part. + @Layout { x = 21; y = 56; width = 328; height = 108; } + metadata Layout about errorReq { x = 24; y = 108; width = 322; height = 14; } + metadata placed : Layout about errorCBE { x = 24; y = 122; width = 322; height = 14; } + + view details { + expose errorReq; + @Canvas { unit = "px"; width = 1006; height = 570; } + metadata Layout about errorReq { x = 1; y = 2; } + render asTreeDiagram; + } + } + + // A rendering usage declared outside a view is an ordinary member. + rendering asTree : GraphicalRendering; +} + +package BudgetViews { + private import Views::*; + + view budgetView { + expose Budget; + render asTreeDiagram; + } +} diff --git a/internal/ir/view/tree.go b/internal/ir/view/tree.go index 850ac417f..3a81c22ee 100644 --- a/internal/ir/view/tree.go +++ b/internal/ir/view/tree.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) @@ -70,13 +71,13 @@ func (r *Renderer) treeNode(view, sym *symbols.Symbol, ids *nodeIDs, seen map[*s return node } if depth >= maxTreeDepth { - if len(containedMembers(sym)) > 0 { + if len(r.containedMembers(sym)) > 0 { node.Detail = detailWith(node.Detail, fmt.Sprintf("nested deeper than %d levels; not shown", maxTreeDepth)) } return node } seen[sym] = true - for _, member := range containedMembers(sym) { + for _, member := range r.containedMembers(sym) { node.Children = append(node.Children, r.treeNode(view, member, ids, seen, depth+1, false, out)) } return node @@ -86,7 +87,7 @@ func (r *Renderer) treeNode(view, sym *symbols.Symbol, ids *nodeIDs, seen map[*s // declaration order: the elements a containment tree shows beneath it. A // reference member is anonymous, so the two member lists a scope keeps are // merged by source position rather than concatenated. -func containedMembers(sym *symbols.Symbol) []*symbols.Symbol { +func (r *Renderer) containedMembers(sym *symbols.Symbol) []*symbols.Symbol { if sym == nil || sym.Scope == nil { return nil } @@ -94,7 +95,7 @@ func containedMembers(sym *symbols.Symbol) []*symbols.Symbol { seen := map[*symbols.Symbol]bool{} for _, list := range [][]*symbols.Symbol{sym.Scope.Members(), sym.Scope.AnonymousMembers()} { for _, member := range list { - if member == sym || seen[member] || !containedKind(member) { + if member == sym || seen[member] || !r.contentKind(member) { continue } seen[member] = true @@ -105,6 +106,21 @@ func containedMembers(sym *symbols.Symbol) []*symbols.Symbol { return out } +// contentKind reports whether a member is model content a rendering shows, as +// against what only describes a picture of it: a view's `render` members and +// the DiagramLayout annotations. Ordinary metadata on an element is content. +func (r *Renderer) contentKind(sym *symbols.Symbol) bool { + if !containedKind(sym) { + return false + } + if sym.Kind == symbols.SymbolRenderingUsage && sym.OwnerScope != nil { + if owner := sym.OwnerScope.Owner(); owner != nil && semantics.IsView(owner) { + return false + } + } + return !r.model.IsLayoutAnnotation(sym) +} + // containedKind reports whether a member is an element of the model a rendering // shows, as against the annotations and bookkeeping declarations that carry no // structure: documentation, comments, aliases and the ends of a connect clause, diff --git a/internal/semantic/semantics/layout.go b/internal/semantic/semantics/layout.go index c3711dd1a..d2437c7c8 100644 --- a/internal/semantic/semantics/layout.go +++ b/internal/semantic/semantics/layout.go @@ -143,6 +143,33 @@ func (m *Model) LayoutSitesOf(sym *symbols.Symbol) []*LayoutSite { return out } +// IsLayoutAnnotation reports whether sym is a metadata usage typed by one of +// the DiagramLayout definitions: a statement about a picture, not model content. +func (m *Model) IsLayoutAnnotation(sym *symbols.Symbol) bool { + if m == nil || sym == nil || m.resolver == nil { + return false + } + var a annotation + var ok bool + switch decl := sym.Decl.(type) { + case *ast.Usage: + if decl.Kind != ast.UsageMetadata { + return false + } + a, ok = m.usageAnnotation(sym.OwnerScope, decl) + case *ast.PrefixMetadata: + a, ok = m.prefixAnnotation(sym.OwnerScope, decl) + } + if !ok || a.typ == nil { + return false + } + switch m.fqnOf(a.typ) { + case LayoutFQN, RouteFQN, CanvasFQN: + return true + } + return false +} + // LayoutOf resolves the Layout of elem as drawn in view: the first Layout // annotation stated in the view's body, else the first one applying in every // view. With no view — a rendering of elements outside any view — only the From a24f053ab97a4c4df592425d6068f3ac632be546 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:39:40 +0000 Subject: [PATCH 02/17] fix(view): head a member drawn under its owner by its name below that owner Co-Authored-By: jason.han --- internal/ir/view/label.go | 61 ++++++++++++++++++++++++++++++---- internal/ir/view/label_test.go | 53 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/internal/ir/view/label.go b/internal/ir/view/label.go index aedd36990..cc348f95c 100644 --- a/internal/ir/view/label.go +++ b/internal/ir/view/label.go @@ -7,13 +7,16 @@ import ( ) // labeller heads the nodes of one rendering the way a diagram names them: a -// node by its name under the namespace every root shares, a type by its own name. +// node by its name under the namespace every root shares, or under the drawn +// owner that holds it, a type by its own name. type labeller struct { - context []string // the shared namespace's names, outermost first + context []string // the shared namespace's names, outermost first + owned map[*Node]string // a node's name relative to the nearest drawn owner } -// labelsOf finds the namespace the named roots share: the longest run of -// leading names common to their qualifiers. +// labelsOf finds the namespace the named roots share — the longest run of +// leading names common to their qualifiers — and, for every node whose +// qualified name continues that of another drawn node, its name below that owner. func labelsOf(roots []*Node) labeller { var context []string found := false @@ -36,11 +39,57 @@ func labelsOf(roots []*Node) labeller { } context = context[:n] } - return labeller{context: context} + return labeller{context: context, owned: ownedNames(roots)} } -// name is a node's name with the shared namespace left off the front. +// ownedNames names each node relative to the nearest drawn node whose +// qualified name its own continues. A root's name is its qualified name; a +// child named with one name is named under its parent, one named with a +// qualified name stands on its own, as a nested view's exposed elements do. +func ownedNames(roots []*Node) map[*Node]string { + drawn := map[string]bool{} + qualified := map[*Node][]string{} // the nodes named with a qualifier: the ones to shorten + var walk func(nodes []*Node, root bool, parent []string) + walk = func(nodes []*Node, root bool, parent []string) { + for _, node := range nodes { + var names []string + if node.Name != "" { + segments, ok := source.QualifiedNameSegments(node.Name) + switch { + case !ok: + case len(segments) > 1: + names, qualified[node] = segments, segments + case root: + names = segments + case parent != nil: + names = append(slices.Clone(parent), segments[0]) + } + } + if names != nil { + drawn[source.QualifiedNameOf(names)] = true + } + walk(node.Children, false, names) + } + } + walk(roots, true, nil) + owned := map[*Node]string{} + for node, names := range qualified { + for n := len(names) - 1; n > 0; n-- { + if drawn[source.QualifiedNameOf(names[:n])] { + owned[node] = source.QualifiedNameOf(names[n:]) + break + } + } + } + return owned +} + +// name is a node's name below its nearest drawn owner, or else with the +// shared namespace left off the front. func (l labeller) name(node *Node) string { + if name, ok := l.owned[node]; ok { + return name + } if len(l.context) == 0 { return node.Name } diff --git a/internal/ir/view/label_test.go b/internal/ir/view/label_test.go index 7cd4025af..d5ea94321 100644 --- a/internal/ir/view/label_test.go +++ b/internal/ir/view/label_test.go @@ -72,6 +72,59 @@ func TestLabelsHeadRootsUnderTheirSharedNamespace(t *testing.T) { } } +// A node whose qualified name continues that of another drawn node is headed +// by its name below that owner — the nearest one drawn — whether the owner is +// a root or a child named under its parent; a node whose owner is not drawn +// keeps its name under the shared namespace, and a nested view's exposed +// elements, named in full, are owners in their own right. +func TestLabelsHeadMembersUnderTheirDrawnOwner(t *testing.T) { + roots := []*Node{ + {Kind: "part def", Name: "TMT::Budget::'K-Mirror Offset'", Children: []*Node{ + {Kind: "attribute", Name: "errorReq", Type: "ScalarValues::Real"}, + {Kind: "part", Name: "stage", Children: []*Node{{Kind: "port", Name: "inlet"}}}, + }}, + {Kind: "attribute", Name: "TMT::Budget::'K-Mirror Offset'::errorReq", Type: "ScalarValues::Real"}, + {Kind: "part", Name: "TMT::Budget::'K-Mirror Offset'::'interpolation Error'", Type: "TMT::Budget::'Interpolation Error'"}, + {Kind: "port", Name: "TMT::Budget::'K-Mirror Offset'::stage::inlet"}, + {Kind: "attribute", Name: "TMT::Budget::'Shear Plate'::errorCBE"}, + {Kind: "attribute", Name: "TMT::Budget::'K-Mirror Offset'::'interpolation Error'::deep::errorMargin"}, + {Kind: "view", Name: "TMT::Budget::Views::details", Children: []*Node{ + {Kind: "part def", Name: "TMT::Budget::Lens"}, + {Kind: "attribute", Name: "TMT::Budget::Lens::focal"}, + }}, + } + labels := labelsOf(roots) + want := []string{ + "'K-Mirror Offset'", + "errorReq : Real", + "'interpolation Error' : 'Interpolation Error'", + "inlet", + "'Shear Plate'::errorCBE", + "deep::errorMargin", + "Views::details", + } + var got []string + for _, root := range roots { + got = append(got, labels.head(root)) + } + if strings.Join(got, "\x00") != strings.Join(want, "\x00") { + t.Errorf("heads = %q, want %q", got, want) + } + for node, want := range map[*Node]string{ + roots[0].Children[0]: "errorReq : Real", + roots[0].Children[1].Children[0]: "inlet", + roots[6].Children[1]: "focal", + } { + if got := labels.head(node); got != want { + t.Errorf("child %q headed %q, want %q", node.Name, got, want) + } + } + rendering := &Rendering{View: "V", Kind: KindTree, Roots: roots} + if text := rendering.Text(); !strings.Contains(text, "attribute TMT::Budget::'K-Mirror Offset'::errorReq : ScalarValues::Real\n") { + t.Errorf("the text form is headed by the diagram label:\n%s", text) + } +} + // The text form keeps the keyword leading, writes the type after a colon and // the notes in parentheses after it. func TestTextLabelShape(t *testing.T) { From 109f8dc4bdc2ac9c6c099b1dc9aaf1083ded8016 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:58:39 +0000 Subject: [PATCH 03/17] feat(view): fit a stated box's label to it and draw symbol kinds as their notation A node a Layout sizes keeps its box: the head word-wraps at the width and shrinks from 14pt to 8pt until it fits the height, the keyword and detail lines follow only while height remains, and a head too long at 8pt is cut and ellipsized. Decision, merge and choice nodes in stated boxes are drawn as diamonds, forks and joins as filled bars, initial nodes as the filled dot, final nodes and terminate actions as the double ring, and ports as their square, with no text inside; a name is set beside the symbol unless the node marks it as synthesized. A terminate action usage is kinded as one, so the notation's final node is recognised. Co-Authored-By: jason.han --- internal/ir/view/behavior.go | 7 ++ internal/ir/view/dot.go | 176 +++++++++++++++++++++++++++- internal/ir/view/dot_fit_test.go | 194 +++++++++++++++++++++++++++++++ internal/ir/view/dot_test.go | 10 +- internal/ir/view/view.go | 3 + tests/migrate/layout_test.go | 4 +- 6 files changed, 383 insertions(+), 11 deletions(-) create mode 100644 internal/ir/view/dot_fit_test.go diff --git a/internal/ir/view/behavior.go b/internal/ir/view/behavior.go index 5191257e8..69c60fe09 100644 --- a/internal/ir/view/behavior.go +++ b/internal/ir/view/behavior.go @@ -161,6 +161,10 @@ func (r *Renderer) transitionEdges(view, machine *symbols.Symbol, graph *lower.S } } +// terminateKind is the Kind of a terminate action usage (`action final terminate;`), +// the activity's final node, drawn as the final symbol when a box places it. +const terminateKind = "terminate action" + // startKind is the Kind of the node a body's entry transitions leave, which a // state diagram draws as its start marker. const startKind = "start" @@ -552,6 +556,9 @@ func actionNodeKind(node ast.Node, graph *lower.ActionGraph) string { case *ast.StateNode: return "state" case *ast.Usage: + if lower.IsTerminateUsage(n) { + return terminateKind + } return n.Kind.String() case *ast.Definition: return n.Kind.String() + " def" diff --git a/internal/ir/view/dot.go b/internal/ir/view/dot.go index 2f87927f2..7fa16b9e8 100644 --- a/internal/ir/view/dot.go +++ b/internal/ir/view/dot.go @@ -255,7 +255,7 @@ func (w *dotWriter) routedBox(node *Node, ends []routeEnd) nodeBox { // dotReach is the distance from the centre of a node's shape to its border along // a unit direction: a round pseudo-state's radius, the edge of a box otherwise. func dotReach(node *Node, width, height, ux, uy float64) float64 { - if node.Kind == startKind || node.Kind == "initial" || node.Kind == "final" { + if dotRound(node) { return width / 2 } reach := math.Inf(1) @@ -400,10 +400,13 @@ func (w *dotWriter) writeEdge(from, to string, attrs []string) { // position and size when a box places it. func (w *dotWriter) dotNodeAttributes(node *Node) []string { var attrs []string - switch node.Kind { - case startKind: + stated := node.Geometry != nil && node.Geometry.HasSize + switch { + case node.Kind == startKind: attrs = []string{"shape=point", "fillcolor=black", `label=""`} - case "initial", "final": + case stated && isSymbolKind(node.Kind): + attrs = w.dotSymbolAttributes(node) + case node.Kind == "initial" || node.Kind == "final": attrs = w.dotPseudostateAttributes(node) default: if !controlKinds[node.Kind] && !isDefinitionKind(node.Kind) { @@ -412,7 +415,11 @@ func (w *dotWriter) dotNodeAttributes(node *Node) []string { if w.fills.filled(node) { attrs = append(attrs, "fillcolor="+dotQuote(w.fills.fill(node)), dotColorAttr(w.fills.color(node)), "penwidth=1") } - attrs = append(attrs, w.labels.dotLabel(node)) + if stated { + attrs = append(attrs, w.labels.dotFittedLabel(node, node.Geometry.Width, node.Geometry.Height)) + } else { + attrs = append(attrs, w.labels.dotLabel(node)) + } } if box, ok := w.boxes[node.ID]; ok { width, height := w.labels.dotBox(node) @@ -420,7 +427,7 @@ func (w *dotWriter) dotNodeAttributes(node *Node) []string { if node.Kind != startKind { attrs = append(attrs, "width="+dotInches(width), "height="+dotInches(height)) } - if g := node.Geometry; g != nil && g.HasSize { + if stated { attrs = append(attrs, "fixedsize=true") } if g := node.Geometry; g != nil && g.Collapsed { @@ -430,6 +437,58 @@ func (w *dotWriter) dotNodeAttributes(node *Node) []string { return attrs } +// isSymbolKind reports whether a kind has a notation symbol a stated box is drawn +// as, with no text inside it: the control and pseudo-state nodes and a port. +func isSymbolKind(kind string) bool { + switch kind { + case "initial", "final", terminateKind, "fork", "join", "merge", "decision", "choice", "junction": + return true + } + return isPortKind(kind) +} + +// dotRound reports whether a node is drawn round, so an edge reaches its border +// at its radius: the start point, the pseudo-states, and a stated terminate action. +func dotRound(node *Node) bool { + switch node.Kind { + case startKind, "initial", "final": + return true + } + return node.Kind == terminateKind && node.Geometry != nil && node.Geometry.HasSize +} + +// isPortKind reports whether a kind is a port usage: `port`, `ref port`, but no +// `port def`. +func isPortKind(kind string) bool { + return slices.Contains(strings.Fields(kind), "port") && !isDefinitionKind(kind) +} + +// dotSymbolAttributes draws a symbol kind in its stated box as the notation's +// symbol: a diamond, a filled bar, the filled dot or double ring, a port's square. +// A given name is set outside it as `xlabel`; a synthesized one is not drawn. +func (w *dotWriter) dotSymbolAttributes(node *Node) []string { + var attrs []string + switch node.Kind { + case "decision", "merge", "choice": + attrs = []string{"shape=diamond"} + case "fork", "join": + attrs = []string{"fillcolor=black"} + case "initial", "junction": + attrs = []string{"shape=circle", "fillcolor=black"} + case "final", terminateKind: + attrs = []string{"shape=doublecircle", "fillcolor=black"} + default: + if w.fills.filled(node) { + attrs = append(attrs, "fillcolor="+dotQuote(w.fills.fill(node)), dotColorAttr(w.fills.color(node)), "penwidth=1") + } + } + attrs = append(attrs, `label=""`) + if node.Name != "" && !node.NameSynthesized { + attrs = append(attrs, "xlabel="+dotQuote(w.labels.head(node))) + } + return attrs +} + // dotPseudostateSize is the diameter, in points, of an initial or final // pseudo-state drawn as the UML filled dot, with no name to show: 0.2in. const dotPseudostateSize = 14.4 @@ -506,6 +565,111 @@ func (l labeller) dotLabelExtent(node *Node) (width, height float64) { return width, height } +// dotFitFloor is the smallest font size, in points, a stated box's label shrinks to. +const dotFitFloor = 8 + +// dotFittedLabel is a node's label composed to fit a stated box: the head wrapped +// at the box's width and shrunk from the default 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. +func (l labeller) dotFittedLabel(node *Node, width, height float64) string { + lines := l.lines(node) + size, head, fits := dotFitHead(lines[0], width, height) + parts := []string{dotSized(size, ""+dotEscapeLines(head)+"")} + left := height - float64(len(head))*size*dotLineEm + for i := 1; fits && i < len(lines); i++ { + keyword := i == 1 && node.Name != "" + lineSize := size + if keyword { + lineSize = math.Round(size * dotKeywordPointSize / dotFontSize) + } + wrapped := dotWrap(lines[i], dotRunesAcross(width, lineSize, dotGlyphEm)) + used := float64(len(wrapped)) * lineSize * dotLineEm + if used > left { + break + } + left -= used + text := dotEscapeLines(wrapped) + if keyword { + text = "" + text + "" + } + parts = append(parts, dotSized(lineSize, text)) + } + return dotLabelAttribute("<" + strings.Join(parts, "
") + ">") +} + +// dotFitHead wraps a head line into a box at the largest font size, from the +// default down to the floor, at which it fits; when none does, the floor's +// wrapping is cut to the lines the height holds, the last ellipsized. +func dotFitHead(head string, width, height float64) (size float64, lines []string, fits bool) { + for size = dotFontSize; size >= dotFitFloor; size-- { + lines = dotWrap(head, dotRunesAcross(width, size, dotBoldGlyphEm)) + if float64(len(lines))*size*dotLineEm <= height { + return size, lines, true + } + } + size = dotFitFloor + across := dotRunesAcross(width, size, dotBoldGlyphEm) + lines = dotWrap(head, across) + down := max(1, int(height/(size*dotLineEm))) + if len(lines) > down { + lines = lines[:down] + last := []rune(lines[down-1]) + lines[down-1] = string(last[:max(0, min(len(last), across-1))]) + "…" + } + return size, lines, false +} + +// dotRunesAcross is how many glyphs of a font size fit across a width, one at +// least so a line can be written at all. +func dotRunesAcross(width, size, glyph float64) int { + return max(1, int(width/(size*glyph))) +} + +// dotWrap word-wraps text to at most across runes a line, breaking a word longer +// than that at the rune it overruns. +func dotWrap(text string, across int) []string { + var lines []string + line := "" + for _, word := range strings.Fields(text) { + if line != "" && utf8.RuneCountInString(line)+1+utf8.RuneCountInString(word) <= across { + line += " " + word + continue + } + if line != "" { + lines = append(lines, line) + } + runes := []rune(word) + for len(runes) > across { + lines = append(lines, string(runes[:across])) + runes = runes[across:] + } + line = string(runes) + } + if line != "" || len(lines) == 0 { + lines = append(lines, line) + } + return lines +} + +// dotEscapeLines joins lines as HTML-like label text, each escaped. +func dotEscapeLines(lines []string) string { + escaped := make([]string, len(lines)) + for i, line := range lines { + escaped[i] = dotEscape(line) + } + return strings.Join(escaped, "
") +} + +// dotSized wraps label text in a `` when its size is not the +// node's 14pt default. +func dotSized(size float64, text string) string { + if size == dotFontSize { + return text + } + return fmt.Sprintf(`%s`, formatCoord(size), text) +} + // dotPin pins a node at a pixel point: `pos="x,y!"` and `pin=true`. func (w *dotWriter) dotPin(centre Point) string { return fmt.Sprintf("pos=%s, pin=true", dotQuote(w.dotPoint(centre)+"!")) diff --git a/internal/ir/view/dot_fit_test.go b/internal/ir/view/dot_fit_test.go new file mode 100644 index 000000000..addfeffc1 --- /dev/null +++ b/internal/ir/view/dot_fit_test.go @@ -0,0 +1,194 @@ +package view + +import ( + "strings" + "testing" +) + +// stated is a node pinned in a box of the given size, the geometry a Layout states. +func stated(node *Node, width, height float64) *Node { + node.Geometry = &Geometry{X: 0, Y: 0, Width: width, Height: height, HasSize: true} + return 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. +func TestDOTFitsTheLabelToAStatedBox(t *testing.T) { + cases := []struct { + name string + node *Node + want string + }{ + {"room for everything", stated(&Node{ID: "n", Kind: "action", Name: "call", Type: "doTracking"}, 200, 80), + `label=<call : doTracking
«action»>, 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=<Execute
Find and
Identify
Algorithm

«action»>`}, + {"keyword dropped for want of height", stated(&Node{ID: "n", Kind: "action", Name: "call", Type: "doTracking"}, 200, 24), + `label=<call : doTracking>`}, + {"detail only while height remains", stated(&Node{ID: "n", Kind: "state", Name: "idle", Detail: "entry, do"}, 200, 40), + `label=<idle
«state»>`}, + {"detail when it fits", stated(&Node{ID: "n", Kind: "state", Name: "idle", Detail: "entry, do"}, 200, 60), + `label=<idle
«state»
entry, do>`}, + {"a compartment row shrinks to one line", stated(&Node{ID: "n", Kind: "attribute", Name: "errorReq", Type: "Real"}, 449, 14), + `label=<errorReq : Real>, pos="224.5,-7!", pin=true, width=6.236111111111111, height=0.19444444444444445, fixedsize=true];`}, + {"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…>, 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=<Reconf
igurat
ion
>`}, + {"wrapping comes before shrinking", stated(&Node{ID: "n", Kind: "part", Name: "pump", Type: "Pump"}, 60, 40), + `label=<pump :
Pump
>`}, + } + for _, tc := range cases { + dot, err := (&Rendering{View: "V", Kind: KindAction, Roots: []*Node{tc.node}}).DOT() + if err != nil { + t.Fatalf("%s: DOT: %v", tc.name, err) + } + checkDOTSyntax(t, dot) + if !strings.Contains(dot, tc.want) { + t.Errorf("%s: DOT lacks %q:\n%s", tc.name, tc.want, dot) + } + if !strings.Contains(dot, "fixedsize=true") { + t.Errorf("%s: the stated box is not fixed:\n%s", tc.name, 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}}).DOT() + if err != nil { + t.Fatalf("DOT: %v", err) + } + if want := `label=<Execute Find and Identify Algorithm
«action»>, pos="`; !strings.Contains(dot, want) || strings.Contains(dot, "fixedsize") { + t.Errorf("unsized node's DOT lacks %q or fixes its size:\n%s", want, dot) + } +} + +// The fitting estimates with the writer's glyph metrics: dotFitHead wraps a +// head at the runes a bold line of the size holds and picks the largest size +// whose wrapped lines stack within the height. +func TestDOTFitHead(t *testing.T) { + cases := []struct { + head string + width, height float64 + size float64 + lines []string + fits bool + }{ + {"call : doTracking", 200, 80, 14, []string{"call : doTracking"}, true}, + {"call : doTracking", 100, 80, 14, []string{"call :", "doTracking"}, true}, + {"call : doTracking", 100, 20, 8, []string{"call : doTracking"}, true}, + {"errorReq : Real", 449, 14, 11, []string{"errorReq : Real"}, true}, + {"abcdefghijklmnopqrstuvwxyz", 40, 14, 8, []string{"abcdef…"}, false}, + {"abcdefghijklmnopqrstuvwxyz", 40, 30, 8, []string{"abcdefg", "hijklmn", "opqrst…"}, false}, + } + for _, tc := range cases { + size, lines, fits := dotFitHead(tc.head, tc.width, tc.height) + if size != tc.size || fits != tc.fits || strings.Join(lines, "|") != strings.Join(tc.lines, "|") { + t.Errorf("dotFitHead(%q, %v, %v) = %v, %q, %v; want %v, %q, %v", tc.head, tc.width, tc.height, size, lines, fits, tc.size, tc.lines, tc.fits) + } + } + for _, tc := range []struct { + text string + across int + want []string + }{ + {"a b c", 3, []string{"a b", "c"}}, + {"a b c", 1, []string{"a", "b", "c"}}, + {"abcdef gh", 4, []string{"abcd", "ef", "gh"}}, + {" spaced out ", 10, []string{"spaced out"}}, + {"", 5, []string{""}}, + } { + if got := dotWrap(tc.text, tc.across); strings.Join(got, "|") != strings.Join(tc.want, "|") { + t.Errorf("dotWrap(%q, %d) = %q, want %q", tc.text, tc.across, got, tc.want) + } + } +} + +// A symbol kind in a stated box is drawn as its notation with no text inside: +// decision, merge and choice a diamond, fork and join a filled bar, initial a +// filled dot, final and a terminate action the double ring, a port its square. +// A name the rendering has is set beside the symbol; a synthesized one is not +// drawn. Without a stated box the kinds keep their labelled shapes. +func TestDOTSymbolsInStatedBoxes(t *testing.T) { + cases := []struct { + name string + node *Node + want string + }{ + {"decision", stated(&Node{ID: "n", Kind: "decision", Name: "decide2", NameSynthesized: true}, 24, 12), + `"n" [shape=diamond, label="", pos="12,-6!", pin=true, width=0.3333333333333333, height=0.16666666666666666, fixedsize=true];`}, + {"merge", stated(&Node{ID: "n", Kind: "merge"}, 24, 12), `"n" [shape=diamond, label="", pos=`}, + {"choice", stated(&Node{ID: "n", Kind: "choice", Name: "which"}, 24, 12), `"n" [shape=diamond, label="", xlabel="which", pos=`}, + {"fork", stated(&Node{ID: "n", Kind: "fork", Name: "fork", NameSynthesized: true}, 120, 6), + `"n" [fillcolor=black, label="", pos="60,-3!", pin=true, width=1.6666666666666667, height=0.08333333333333333, fixedsize=true];`}, + {"join", stated(&Node{ID: "n", Kind: "join", Name: "sync"}, 120, 6), `"n" [fillcolor=black, label="", xlabel="sync", pos=`}, + {"initial", stated(&Node{ID: "n", Kind: "initial", Name: "start", NameSynthesized: true}, 20, 20), + `"n" [shape=circle, fillcolor=black, label="", pos="10,-10!", pin=true, width=0.2777777777777778, height=0.2777777777777778, fixedsize=true];`}, + {"final", stated(&Node{ID: "n", Kind: "final", Name: "final", NameSynthesized: true}, 20, 20), + `"n" [shape=doublecircle, fillcolor=black, label="", pos=`}, + {"terminate action", stated(&Node{ID: "n", Kind: terminateKind, Name: "final", NameSynthesized: true}, 20, 20), + `"n" [shape=doublecircle, fillcolor=black, label="", pos=`}, + {"named final", stated(&Node{ID: "n", Kind: "final", Name: "done"}, 20, 20), + `"n" [shape=doublecircle, fillcolor=black, label="", xlabel="done", pos=`}, + {"port", stated(&Node{ID: "n", Kind: "port", Name: "cmdIn", Type: "CmdPort"}, 12, 12), + `"n" [label="", xlabel="cmdIn : CmdPort", pos="6,-6!", pin=true, width=0.16666666666666666, height=0.16666666666666666, fixedsize=true];`}, + {"ref port", stated(&Node{ID: "n", Kind: "ref port", Name: "p"}, 12, 12), `"n" [label="", xlabel="p", pos=`}, + } + for _, tc := range cases { + dot, err := (&Rendering{View: "V", Kind: KindAction, Roots: []*Node{tc.node}}).DOT() + if err != nil { + t.Fatalf("%s: DOT: %v", tc.name, err) + } + checkDOTSyntax(t, dot) + if !strings.Contains(dot, tc.want) { + t.Errorf("%s: DOT lacks %q:\n%s", tc.name, tc.want, dot) + } + if strings.Contains(dot, "«") { + t.Errorf("%s: a symbol carries a keyword line:\n%s", tc.name, dot) + } + } + // A port under a palette keeps its family fill, so the square is coloured. + dot, err := (&Rendering{View: "V", Kind: KindInterconnection, Roots: []*Node{stated(&Node{ID: "n", Kind: "port", Name: "p"}, 12, 12)}}).DOTWith(Options{Palette: PaletteOkabeIto}) + if err != nil { + t.Fatalf("DOT: %v", err) + } + if want := `"n" [fillcolor="#99D8C7", color="#009E73", penwidth=1, label="", xlabel="p", pos=`; !strings.Contains(dot, want) { + t.Errorf("palette port DOT lacks %q:\n%s", want, dot) + } + // A port def is a definition, not a symbol; a symbol kind with no stated size + // is labelled as before. + for _, tc := range []struct { + name string + node *Node + want string + }{ + {"port def", stated(&Node{ID: "n", Kind: "port def", Name: "CmdPort"}, 120, 40), + `label=<CmdPort
«port def»>`}, + {"unsized decision", &Node{ID: "n", Kind: "decision", Name: "decide", Geometry: &Geometry{X: 5, Y: 5}}, + `"n" [label=<decide
«decision»>, pos=`}, + {"unsized terminate action", &Node{ID: "n", Kind: terminateKind, Name: "final"}, + `"n" [style="rounded,filled", label=<final
«terminate action»>];`}, + } { + dot, err := (&Rendering{View: "V", Kind: KindAction, Roots: []*Node{tc.node}}).DOT() + if err != nil { + t.Fatalf("%s: DOT: %v", tc.name, err) + } + if !strings.Contains(dot, tc.want) { + t.Errorf("%s: DOT lacks %q:\n%s", tc.name, tc.want, dot) + } + } +} + +// A synthesized name is drawn nowhere a symbol stands, but a plain node keeps +// its name in the box: the bit only silences the text beside a symbol. +func TestDOTSynthesizedNameOnAPlainNode(t *testing.T) { + dot, err := (&Rendering{View: "V", Kind: KindAction, Roots: []*Node{stated(&Node{ID: "n", Kind: "action", Name: "send2", NameSynthesized: true}, 100, 40)}}).DOT() + if err != nil { + t.Fatalf("DOT: %v", err) + } + if want := `label=<send2
«action»>`; !strings.Contains(dot, want) { + t.Errorf("DOT lacks %q:\n%s", want, dot) + } +} diff --git a/internal/ir/view/dot_test.go b/internal/ir/view/dot_test.go index 996280ff3..d927cfd7c 100644 --- a/internal/ir/view/dot_test.go +++ b/internal/ir/view/dot_test.go @@ -490,6 +490,7 @@ func TestDOTWritesTheGeometry(t *testing.T) { } } // Without a canvas height, y is negated; the inline Layout of pump is kept, + // its head wrapped where eleven bold 14pt glyphs overrun the stated 100pt width, // and tank, with none, takes the end of the inline route: its 118x37 // label-fitted box centred 59 back from (200, 45) along the route's last leg. plain, err := render(t, "layout.sysml", "PlantViews::plainView").DOT() @@ -500,7 +501,7 @@ func TestDOTWritesTheGeometry(t *testing.T) { for _, want := range []string{ "// layout: neato -n2\ndigraph", " graph [fontname=\"Helvetica\", inputscale=72, dpi=72];\n", - `"n1" [style="rounded,filled", label=<pump : Pump
«part»>, pos="60,-45!", pin=true, width=1.3888888888888888, height=0.6944444444444444, fixedsize=true];`, + `"n1" [style="rounded,filled", label=<pump :
Pump

«part»>, pos="60,-45!", pin=true, width=1.3888888888888888, height=0.6944444444444444, fixedsize=true];`, `"n2" [style="rounded,filled", label=<tank : Tank
«part»>, pos="259,-45!", pin=true, width=1.6388888888888888, height=0.5138888888888888];`, `pos="60,-45 60,-45 200,-45 200,-45"`, } { @@ -653,7 +654,8 @@ digraph "Pinned::view" { t.Errorf("tree DOT states a cluster box:\n%s", dot) } // Pseudo-states are centred on their own shapes: a point's fixed size, a - // circle round the label's diagonal. + // circle round the label's diagonal, a stated box drawn as the bare symbol + // with the name beside it. pseudo := &Rendering{View: "V", Kind: KindState, Roots: []*Node{ {ID: "s", Kind: startKind, Geometry: &Geometry{X: 0, Y: 0}}, {ID: "i", Kind: "initial", Name: "go", Geometry: &Geometry{X: 100, Y: 0}}, @@ -667,7 +669,7 @@ digraph "Pinned::view" { for _, want := range []string{ `"s" [shape=point, fillcolor=black, label="", pos="1.8,-1.8!", pin=true];`, `"i" [shape=circle, label=<go
«initial»>, pos="140,-40!", pin=true, width=1.1111111111111112, height=1.1111111111111112];`, - `"f" [shape=doublecircle, label=<done
«final»>, pos="205,-5!", pin=true, width=0.1388888888888889, height=0.1388888888888889, fixedsize=true];`, + `"f" [shape=doublecircle, fillcolor=black, label="", xlabel="done", pos="205,-5!", pin=true, width=0.1388888888888889, height=0.1388888888888889, fixedsize=true];`, } { if !strings.Contains(dot, want) { t.Errorf("pseudo-state DOT lacks %q:\n%s", want, dot) @@ -940,7 +942,7 @@ func checkDOTAttributes(t *testing.T, tokens []dotToken, i int, dot string, clip t.Fatalf("attribute list is not name=value at token %d (%q):\n%s", i, tokens[i].text, dot) } name, value := tokens[i].text, tokens[i+2] - if (name == "label" || name == "lhead" || name == "ltail" || name == "pos" || name == "bb" || name == "comment") && !value.quoted { + if (name == "label" || name == "xlabel" || name == "lhead" || name == "ltail" || name == "pos" || name == "bb" || name == "comment") && !value.quoted { t.Fatalf("attribute %s has a bare value %q:\n%s", name, value.text, dot) } if value.html { diff --git a/internal/ir/view/view.go b/internal/ir/view/view.go index 90f4ed2ea..84c8839a2 100644 --- a/internal/ir/view/view.go +++ b/internal/ir/view/view.go @@ -179,6 +179,9 @@ type Node struct { // Name is the element's name: qualified for a node the view exposes, simple // for one nested in it. It is empty for an anonymous element. Name string + // NameSynthesized marks a name a migration made up for an element its source + // left unnamed: a name to key by, not one a picture shows. + NameSynthesized bool // Type is the declared type of a typed usage, as the notation writes it // after the colon. It is empty for a definition or an untyped usage. Type string diff --git a/tests/migrate/layout_test.go b/tests/migrate/layout_test.go index e5cb6a6bc..df06226c2 100644 --- a/tests/migrate/layout_test.go +++ b/tests/migrate/layout_test.go @@ -184,7 +184,9 @@ func TestGoldenControlNodeLayout(t *testing.T) { } for _, want := range []string{ "// layout: neato -n2\n", - `«fork»
>, pos="100,217!", pin=true, width=1.6666666666666667, height=0.08333333333333333, fixedsize=true];`, + `"n1" [fillcolor=black, label="", xlabel="'fork'", pos="100,217!", pin=true, width=1.6666666666666667, height=0.08333333333333333, fixedsize=true];`, + `"n7" [shape=diamond, label="", xlabel="check", pos="100,70!", pin=true, width=0.2777777777777778, height=0.2777777777777778, fixedsize=true];`, + `"n8" [shape=doublecircle, fillcolor=black, label="", xlabel="final", pos="50,10!", pin=true, width=0.2777777777777778, height=0.2777777777777778, fixedsize=true];`, `«initial»>, pos="100,290!", pin=true, width=1.1111111111111112, height=1.1111111111111112];`, `«final»>, pos="150,-14.5!", pin=true, width=0.9583333333333334, height=0.9583333333333334];`, `"n9" -> "n1" [label="'start to fork'", pos="100,250 100,250 100,220 100,220"];`, From 09618b91224f54f3f00ab8e5330593203c909638 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:03:47 +0000 Subject: [PATCH 04/17] docs(view): describe the fitted labels, symbols, owner-relative heads and the bookkeeping left undrawn Co-Authored-By: jason.han --- .../positioned-label-fitting.fixed.md | 4 ++ docs/project/spec-compliance.md | 4 +- docs/project/view-rendering-forms.md | 52 ++++++++++++++++--- docs/reference/cli.md | 5 +- 4 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 changes/unreleased/positioned-label-fitting.fixed.md diff --git a/changes/unreleased/positioned-label-fitting.fixed.md b/changes/unreleased/positioned-label-fitting.fixed.md new file mode 100644 index 000000000..4bdb219f5 --- /dev/null +++ b/changes/unreleased/positioned-label-fitting.fixed.md @@ -0,0 +1,4 @@ +- **A positioned node's label fits the box its `DiagramLayout::Layout` states; the box is never grown to the label.** The DOT writer word-wraps the head at the stated width and draws it at the largest font size from 14 pt down to 8 pt at which the wrapped lines fit the height, keeps the `«keyword»` and detail lines only while height remains, and cuts and ellipsizes a head that overruns even at 8 pt, using the same glyph estimate the unsized boxes are fitted with. A migrated Cameo diagram, whose boxes were sized for the name alone, reads as it did: a 449×14 px attribute row holds its one line, `call : doTracking` no longer spills out of its action box, and Graphviz's `size too small for label` warnings on such a model drop to none. Nodes without a stated size are drawn exactly as before. +- **A control node or port in a stated box is drawn as its notation symbol, with no text inside.** A decision, merge or choice is a diamond, a fork or join the filled bar, an initial node the filled dot, a final node or terminate action the double ring, and a port its small square; the node's name is set beside the symbol as an `xlabel`, and left out when the view IR marks it as one a migration synthesized (`Node.NameSynthesized`). Without a stated box these kinds keep their labelled shapes. +- **A member drawn under its owner is headed by its name below that owner.** A nested node, or an exposed element whose owner is drawn in the same rendering, no longer repeats the owner's qualified path: `'K-Mirror Offset'::'interpolation Error' : 'Interpolation Error'` inside the `'K-Mirror Offset'` box reads `'interpolation Error' : 'Interpolation Error'`, as a diagram frame shows it. Only the graphical forms' heads change; the text and JSON forms and the LSP keep the qualified name. +- **A view's layout annotations and `render` members are not drawn as nodes.** The member walk every rendering kind shares leaves out `DiagramLayout::Canvas`, `Layout` and `Route` annotations, wherever they are owned, and the `render` members a view holds, so a tree over a package of migrated views no longer fills with `metadata`, `x`, `y`, `width`, `height` and `asTreeDiagram` nodes. Every other metadata usage, and a rendering usage outside a view, is drawn as before. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 000268b21..d9e12653f 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -2010,8 +2010,8 @@ semantics layer over the conjugation parity of the typing/specialization chain. | A view's exposed elements are queryable (SysML v2 7.24 Views and Viewpoints, 8.3.26 Expose) | `semantics/expose.go` `Model.ExposedElements` (a view's own `expose` relationships, then those of the views it specializes since an Expose is protected, in declaration order and once each) and `Model.NestedViews` (the views in its body, to walk a view tree), enumerated by `resolve/filter.go` `Resolver.ImportedElements` — the same admission, visibility and filter gating a lookup through that import makes, so the exposed set is what the view body actually resolves | `semantics/expose_test.go` (`TestExposedElementsNamespaceWildcard`, `TestExposedElementsRecursiveExpose`, `TestExposedElementsWithAnElementFilter`, `TestExposedElementsWithAViewBodyFilter`, `TestExposedElementsOfNestedViews`, `TestExposedElementsExposingAnotherView`, `TestExposedElementsInheritedFromAViewDefinition`, `TestExposedElementsOfAViewExposingNothing`, `TestExposedElementsOfANonView`) | ✅ Faithful (an empty exposed set is no error; asking a non-view is `semantics.ErrNotAView`. The REPL surface is `%view` — `repl/view.go` `doView`) | | A view's exposed set is rendered as the rendering its `render` member states, and as a containment tree where it states none (SysML v2 7.24 Views and Viewpoints, §10.2 — the rendering is tool-defined) | `semantics/rendering.go` `Model.ViewRenderings` (the `render` members of the view and of the views it specializes) and `Model.RenderingTarget` (the rendering the member references or declares); `view/view.go` `Renderer.KindOf`, `Renderer.Render` (the kind, then the exposed set from `Model.ExposedElements`) and `Renderer.RenderExposed` (an arbitrary selected set); `view/pseudo.go` derives the `#` vocabulary from the kinds this build supports; `view/tree.go`, `view/interconnection.go` (the model's own connector and flow ends, not source text), `view/behavior.go` (the lowered `lower.StateGraph`/`lower.ActionGraph`, never a re-parse of `symbol.Decl`), `view/table.go` (the exposed elements, the elements declared in them and the nested views, as rows), `view/sequence.go` `Renderer.renderSequence` (the occurrences an interaction declares as lifelines, the model's own flow ends as directed messages, ordered by the successions between the events those messages run between), `view/text.go`, `view/mermaid.go` and `view/markdown.go` (the forms, chosen per kind by `Kind.MachineForm` in `view/form.go`) | `view/render_test.go` `TestGoldenRenderings` (text and machine-readable goldens for tree, interconnection, state, action, a filtered view and a table, from `.sysml` fixtures), `TestTreeRenderingShowsNestedViewsAndDefaults`, `TestInterconnectionRenderingDrawsConnections`, `TestStateRenderingComesFromTheLoweredGraph`, `TestActionRenderingComesFromTheLoweredGraph`, `TestRenderingUsesFilteredAndInheritedExposure`, `TestTableRenderingRows`, `TestTableFormsAreMarkdownNotMermaid`, `TestMermaidLabelsAreEscaped`; `view/sequence_test.go` `TestSequenceRenderingDrawsLifelinesAndMessages`, `TestSequenceRenderingFromTheShortViewName`, `TestSequenceRenderingReportsWhatItCannotShow`, `TestSequenceRenderingHonoursStatedOrder`, `TestSequenceRenderingReportsASuccessionCycle`, `TestSequenceMermaidDeclaresParticipantsFirst`; `view/pseudo_test.go` | ✅ Faithful to the notation, tool-defined in output (the kinds produced are a tree, an interconnection diagram, a state machine, an action flow, a sequence diagram and a table; state and action renderings read the graphs the runtime executes, so a rendering cannot drift from what runs. Mermaid is the machine-readable form of the graph-shaped kinds and Markdown that of a table; SysML §10.2 specifies no artifact) | | A rendering this build does not produce, a name that is no view, a view exposing nothing, and an exposed element a rendering cannot represent are each explicit | `view/view.go` `UnsupportedKindError` (wrapping `view.ErrUnsupportedKind`, naming the kind, the view and the rendering it stated), `Renderer.Render` (`semantics.ErrNotAView` for a non-view, as `%view` answers), `Rendering.Empty` and `view/text.go` (an empty artifact saying whether the view exposes nothing or nothing exposed was representable), `Rendering.Notices` (what a kind could not draw); `cmd/sysml/render.go` reports and skips unsupported declared views and incompatible forced forms during `-render-all`, prefixing each notice with its source view | `view/render_test.go` `TestUnsupportedRenderingKinds`, `TestRenderingSomethingThatIsNoView`, `TestRenderingAViewExposingNothing`, `TestRenderingReportsWhatItCannotRepresent`; `repl/view_render_test.go` `TestRenderOfAnUnsupportedKindNamesIt`, `TestRenderOfANonViewIsTyped`, `TestRenderOfAViewExposingNothingSaysSo`; `cmd/sysml/render_test.go` `TestRenderReportsWhatItCouldNotDo`, `TestRenderAllSkipsUnsupportedKindsAndWrongForcedForms`, `TestRenderAllPrefixesRenderingNoticesWithTheirView` | ✅ Faithful (a stated kind that is not produced is a typed error naming it, never a substituted rendering; a form the kind is not written in is a `WrongFormError` naming the one it is; an element that cannot be drawn is reported, not dropped. A one-view render stops on either typed error; a render-all run skips only that view and renders the rest) | -| A graph-shaped rendering (tree, interconnection, state, action) is also written as Graphviz DOT, an alternative to Mermaid for Graphviz toolchains and large-graph layouts, without a Graphviz installation: a `digraph` with `// view:`, `// kind:`, `// stated:`, `// not represented:`, `// canvas:` and `// layout:` header comments, `rankdir` from the direction, containment as `subgraph "cluster_"` (a tree as `arrowhead=none` edges, as its Mermaid form), an edge at a cluster drawn to a node inside it and clipped with `lhead`/`ltail`, state pseudo-states as `point`/`circle`/`doublecircle`, states as rounded boxes, regions as dashed clusters, transition labels as the state writer's trigger/guard/effect text, and edge kinds parallel to the Mermaid arrows (connection `arrowhead=none`, flow `style=dashed`, transition and succession solid); every identifier and label is quoted through one helper; the DiagramLayout geometry is written as Graphviz reads it, one pixel to one point with y flipped from the library's y-down origin (`inputscale=72`, `dpi=72`; y measured from the canvas's bottom edge, negated with no canvas height): a positioned node pinned at the centre of its box with `pos="x,y!", pin=true`, its size as `width`/`height` in inches — a stated size with `fixedsize=true`, an unstated one fitted to the label so the box's corner stays where the Layout put it — `collapsed` as `comment="collapsed"`, a positioned cluster's `bb` stated (its stated box, or the one from its corner round its positioned members) and its anchor pinned at the centre, a `Route` as the edge's `pos` B-spline through its waypoints (a route of one waypoint drawn as no line and noticed as `// not represented:`), a sized `Canvas` as an invisible point pinned at each corner so the drawing's bounding box is the canvas, and the `// layout:` header naming `neato -n2` when every node is placed and any edge routed, `neato -n` when every node is placed and none routed, `neato` when some nodes are, `dot` when none; sequence and table have no DOT form and are the same `WrongFormError` the other forms raise | `view/dot.go` `Rendering.DOT`, `Rendering.DOTWith`, `dotWriter` (`engine`, `graphAttributes`, `dotNodeAttributes`, `dotPin`, `dotBox`, `dotClusterAttributes`, `dotAnchorAttributes`, `clusterBox`, `dotEdgeAttributes`, `dotSpline`, `flipY`, `dotInches`), `dotQuote`; `view/form.go` `FormDot`, `Forms`, `DiagramForms`, `Kind.SupportsForm`, `Options`, `Write`, `WriteWith`; `cmd/sysml/render.go` (`-render-form dot`, `.dot` under `-render-all`); `repl/meta.go` (`%render dot`); `lsp/render.go` `renderForm` (`form: "dot"`) | `view/dot_test.go` `TestGoldenDOT` (`testdata/*.dot.golden` beside the Mermaid goldens, each checked by an in-test DOT syntax walker: balanced braces, every edge endpoint declared as a node or cluster, quoted identifiers), `TestDOTFormSupport`, `TestDOTQuotesEveryIdentifierAndLabel`, `TestDOTNestedClusters`, `TestDOTDirections`, `TestDOTEdgeKinds`, `TestDOTStateShapesAndLabels`, `TestDOTEmptyAndNotices`, `TestDOTWritesTheGeometry` (`testdata/layout.dot.golden` beside the Mermaid and text goldens of the layout fixture; the flipped axis with and without a canvas height; states and transitions placed), `TestDOTPinsEveryNode` (`neato -n` and `neato -n2`, the one-waypoint notice, a stated, a member-fitted and a corner-only cluster's `bb` and anchor, a tree's positioned parent, pseudo-state centring, the zero-extent, unit-only and unpositioned canvas); the syntax walker parses every `pos` and `bb`; `cmd/sysml/render_test.go` `TestRenderDotForm`; `repl/view_render_test.go` `TestRenderWritesDotWhenAskedFor`; `lsp/render_test.go` `TestRenderWritesDotWhenAskedFor` | ✅ Faithful to the notation, tool-defined in output (Mermaid stays the machine-readable form `Kind.MachineForm` chooses; DOT is written on request. Producing the DOT needs no `dot` binary, and the goldens are validated by the in-test syntax walker rather than by `dot -Tsvg`; the one place Graphviz runs is the PDF backend, drawing the block on request when a Graphviz is installed (the document-rendering rows below). A `Route` is written as the polyline through its waypoints, not smoothed; a node with no stated size is given the writer's estimate of its label's extent (8.4 pt a glyph, 16.8 pt a line), not `fixedsize`, so Graphviz may grow the box for its own font and move the corner by the difference. The gRPC API has no view-render RPC — `RenderDocument` alone, to Markdown — so no wire contract carries a form) | -| The DOT form draws in the Standard B&W style of the OMG SysML v2 Pilot Implementation's PlantUML visualizer (SysML v2 §8.2.2.2, the graphical notation's rendering being tool-defined), after the `sysmlbw` PlantUML skin by Hisashi Miyashita (Mgnite Inc.) shipped with the Pilot and the edge rules of its `SysML2PlantUMLStyle.java`, reproducing the skin's visual parameters rather than its text: Helvetica text at 14 pt on nodes and 13 pt on edges, white fills, `#181818` lines at `penwidth=0.5` on nodes and `1` on edges, a definition (`… def`, or a KerML classifier keyword) square and a usage `style="rounded,filled"`, the name in bold over the `«keyword»` line in italics at its 10 pt size, clusters unfilled with black borders — `penwidth=1.5` for a package, `0.5` for an element's cluster and a region (which keeps `style=dashed`) — a connection at `penwidth=3` with `arrowhead=none`, flow, succession and transition as before, and an unnamed initial or final pseudo-state as the filled black UML dot (`shape=circle`/`doublecircle`, `fillcolor=black`, `label=""`, `width=0.2` unless a Layout sizes it) while a named one keeps its labelled ring; every style attribute precedes the geometry in a node's list, and geometry, node IDs, label text, escaping, routes, cluster anchors, `lhead`/`ltail`, the header comments and the order of nodes and edges are the ones the DOT form always wrote | `view/dot.go` `dotNodeDefaults`, `dotEdgeDefaults`, `dotControlKinds`, `dotNodeAttributes`, `dotPseudostateAttributes`, `dotClusterAttributes`, `dotClusterPenwidth`, `dotEdgeAttributes`, `dotLabel`; `view/palette.go` `isDefinitionKind`, `kermlClassifierKinds` | `view/dot_style_test.go` `TestDOTStandardDefaults`, `TestDOTDefinitionsSquareUsagesRounded`, `TestDOTPseudostateRules`, `TestDOTClusterBorders`, `TestDOTConnectionPenwidth`, `TestDOTEscapesNamesInStyledLabels`; every `view/testdata/*.dot.golden`, reviewed so that only style attributes and the italic keyword markup moved; `docrender/testdata/*.golden.*`, `repl/view_render_test.go`, `cmd/sysml/render_test.go`, `lsp/render_test.go` | ⚠️ Approximate (the skin's 20-unit `UsageRoundCorner` is Graphviz's fixed `rounded` radius; its `Shadowing 0`, `hide circle` and `wrapWidth 300` have no Graphviz counterpart and nothing to turn off; the skin's plain-weight state title is not followed — a state's name stays bold like every other kind's, so the text, Mermaid and DOT forms read alike; the Pilot's `-[thickness=5]-` binding connectors are not drawn apart from connections because the interconnection rendering has no edge kind for them; the skin's notes, sequence, gantt, mindmap and wbs sections are out of the DOT form's scope. Producing DOT still runs no Graphviz binary; the goldens are checked by the in-test syntax walker, and a Graphviz installation is used only by hand to look at them) | +| A graph-shaped rendering (tree, interconnection, state, action) is also written as Graphviz DOT, an alternative to Mermaid for Graphviz toolchains and large-graph layouts, without a Graphviz installation: a `digraph` with `// view:`, `// kind:`, `// stated:`, `// not represented:`, `// canvas:` and `// layout:` header comments, `rankdir` from the direction, containment as `subgraph "cluster_"` (a tree as `arrowhead=none` edges, as its Mermaid form), an edge at a cluster drawn to a node inside it and clipped with `lhead`/`ltail`, state pseudo-states as `point`/`circle`/`doublecircle`, states as rounded boxes, regions as dashed clusters, transition labels as the state writer's trigger/guard/effect text, and edge kinds parallel to the Mermaid arrows (connection `arrowhead=none`, flow `style=dashed`, transition and succession solid); every identifier and label is quoted through one helper; the DiagramLayout geometry is written as Graphviz reads it, one pixel to one point with y flipped from the library's y-down origin (`inputscale=72`, `dpi=72`; y measured from the canvas's bottom edge, negated with no canvas height): a positioned node pinned at the centre of its box with `pos="x,y!", pin=true`, its size as `width`/`height` in inches — a stated size with `fixedsize=true` and the label fitted to it (the head wrapped at the width and shrunk from 14 pt to 8 pt until it fits, the keyword and detail lines kept only while height remains, an overrunning head ellipsized), an unstated one fitted to the label so the box's corner stays where the Layout put it — `collapsed` as `comment="collapsed"`, a positioned cluster's `bb` stated (its stated box, or the one from its corner round its positioned members) and its anchor pinned at the centre, a `Route` as the edge's `pos` B-spline through its waypoints (a route of one waypoint drawn as no line and noticed as `// not represented:`), a sized `Canvas` as an invisible point pinned at each corner so the drawing's bounding box is the canvas, and the `// layout:` header naming `neato -n2` when every node is placed and any edge routed, `neato -n` when every node is placed and none routed, `neato` when some nodes are, `dot` when none; sequence and table have no DOT form and are the same `WrongFormError` the other forms raise | `view/dot.go` `Rendering.DOT`, `Rendering.DOTWith`, `dotWriter` (`engine`, `graphAttributes`, `dotNodeAttributes`, `dotPin`, `dotBox`, `dotClusterAttributes`, `dotAnchorAttributes`, `clusterBox`, `dotEdgeAttributes`, `dotSpline`, `flipY`, `dotInches`), `dotQuote`; `view/form.go` `FormDot`, `Forms`, `DiagramForms`, `Kind.SupportsForm`, `Options`, `Write`, `WriteWith`; `cmd/sysml/render.go` (`-render-form dot`, `.dot` under `-render-all`); `repl/meta.go` (`%render dot`); `lsp/render.go` `renderForm` (`form: "dot"`) | `view/dot_test.go` `TestGoldenDOT` (`testdata/*.dot.golden` beside the Mermaid goldens, each checked by an in-test DOT syntax walker: balanced braces, every edge endpoint declared as a node or cluster, quoted identifiers), `TestDOTFormSupport`, `TestDOTQuotesEveryIdentifierAndLabel`, `TestDOTNestedClusters`, `TestDOTDirections`, `TestDOTEdgeKinds`, `TestDOTStateShapesAndLabels`, `TestDOTEmptyAndNotices`, `TestDOTWritesTheGeometry` (`testdata/layout.dot.golden` beside the Mermaid and text goldens of the layout fixture; the flipped axis with and without a canvas height; states and transitions placed), `TestDOTPinsEveryNode` (`neato -n` and `neato -n2`, the one-waypoint notice, a stated, a member-fitted and a corner-only cluster's `bb` and anchor, a tree's positioned parent, pseudo-state centring, the zero-extent, unit-only and unpositioned canvas); the syntax walker parses every `pos` and `bb`; `cmd/sysml/render_test.go` `TestRenderDotForm`; `repl/view_render_test.go` `TestRenderWritesDotWhenAskedFor`; `lsp/render_test.go` `TestRenderWritesDotWhenAskedFor` | ✅ Faithful to the notation, tool-defined in output (Mermaid stays the machine-readable form `Kind.MachineForm` chooses; DOT is written on request. Producing the DOT needs no `dot` binary, and the goldens are validated by the in-test syntax walker rather than by `dot -Tsvg`; the one place Graphviz runs is the PDF backend, drawing the block on request when a Graphviz is installed (the document-rendering rows below). A `Route` is written as the polyline through its waypoints, not smoothed; a node with no stated size is given the writer's estimate of its label's extent (8.4 pt a glyph, 16.8 pt a line), not `fixedsize`, so Graphviz may grow the box for its own font and move the corner by the difference. The gRPC API has no view-render RPC — `RenderDocument` alone, to Markdown — so no wire contract carries a form) | +| The DOT form draws in the Standard B&W style of the OMG SysML v2 Pilot Implementation's PlantUML visualizer (SysML v2 §8.2.2.2, the graphical notation's rendering being tool-defined), after the `sysmlbw` PlantUML skin by Hisashi Miyashita (Mgnite Inc.) shipped with the Pilot and the edge rules of its `SysML2PlantUMLStyle.java`, reproducing the skin's visual parameters rather than its text: Helvetica text at 14 pt on nodes and 13 pt on edges, white fills, `#181818` lines at `penwidth=0.5` on nodes and `1` on edges, a definition (`… def`, or a KerML classifier keyword) square and a usage `style="rounded,filled"`, the name in bold over the `«keyword»` line in italics at its 10 pt size, clusters unfilled with black borders — `penwidth=1.5` for a package, `0.5` for an element's cluster and a region (which keeps `style=dashed`) — a connection at `penwidth=3` with `arrowhead=none`, flow, succession and transition as before, and an unnamed initial or final pseudo-state as the filled black UML dot (`shape=circle`/`doublecircle`, `fillcolor=black`, `label=""`, `width=0.2` unless a Layout sizes it) while a named one keeps its labelled ring unless a Layout sizes it; a decision, merge, choice, fork, join, initial, final or port a Layout sizes drawn as its symbol with no inner text, its name as an `xlabel` unless the view IR marks it synthesized; every style attribute precedes the geometry in a node's list, and geometry, node IDs, label text, escaping, routes, cluster anchors, `lhead`/`ltail`, the header comments and the order of nodes and edges are the ones the DOT form always wrote | `view/dot.go` `dotNodeDefaults`, `dotEdgeDefaults`, `dotControlKinds`, `dotNodeAttributes`, `dotPseudostateAttributes`, `dotClusterAttributes`, `dotClusterPenwidth`, `dotEdgeAttributes`, `dotLabel`; `view/palette.go` `isDefinitionKind`, `kermlClassifierKinds` | `view/dot_style_test.go` `TestDOTStandardDefaults`, `TestDOTDefinitionsSquareUsagesRounded`, `TestDOTPseudostateRules`, `TestDOTClusterBorders`, `TestDOTConnectionPenwidth`, `TestDOTEscapesNamesInStyledLabels`; every `view/testdata/*.dot.golden`, reviewed so that only style attributes and the italic keyword markup moved; `docrender/testdata/*.golden.*`, `repl/view_render_test.go`, `cmd/sysml/render_test.go`, `lsp/render_test.go` | ⚠️ Approximate (the skin's 20-unit `UsageRoundCorner` is Graphviz's fixed `rounded` radius; its `Shadowing 0`, `hide circle` and `wrapWidth 300` have no Graphviz counterpart and nothing to turn off; the skin's plain-weight state title is not followed — a state's name stays bold like every other kind's, so the text, Mermaid and DOT forms read alike; the Pilot's `-[thickness=5]-` binding connectors are not drawn apart from connections because the interconnection rendering has no edge kind for them; the skin's notes, sequence, gantt, mindmap and wbs sections are out of the DOT form's scope. Producing DOT still runs no Graphviz binary; the goldens are checked by the in-test syntax walker, and a Graphviz installation is used only by hand to look at them) | | A graph-shaped rendering (tree, interconnection, state, action) and a sequence are also written as PlantUML, the language the OMG Pilot's own visualizer draws with, without Java or a PlantUML jar: an `@startuml` … `@enduml` file with the `' — rendering ()` header comment and one `' not represented:` line per notice and per loss the writer itself incurs, the Standard B&W style inline as a `