Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/unreleased/generalization-routes.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changes/unreleased/keyword-line-fit.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions docs/project/diagram-layout-annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/project/pilot-differential-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions docs/reference/sysml-v1-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions internal/check/edit/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions internal/check/edit/layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions internal/check/passes/diagram/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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{
Expand Down Expand Up @@ -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) {
Expand Down
115 changes: 74 additions & 41 deletions internal/ir/view/dot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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, "<b>"+dotEscapeLines(head)+"</b>")
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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading