sysml2tikz 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sysml2tikz/__init__.py +25 -0
- sysml2tikz/cli.py +51 -0
- sysml2tikz/emit.py +179 -0
- sysml2tikz/layout.py +264 -0
- sysml2tikz/project.py +203 -0
- sysml2tikz/sysml2tikz.sty +115 -0
- sysml2tikz-0.0.1.dist-info/METADATA +142 -0
- sysml2tikz-0.0.1.dist-info/RECORD +10 -0
- sysml2tikz-0.0.1.dist-info/WHEEL +4 -0
- sysml2tikz-0.0.1.dist-info/entry_points.txt +2 -0
sysml2tikz/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Jon R. Fox and Hermes Agent
|
|
3
|
+
"""sysml2tikz — SysML v2 notation for LaTeX/TikZ.
|
|
4
|
+
|
|
5
|
+
Two halves, usable separately:
|
|
6
|
+
|
|
7
|
+
* ``sysml2tikz.sty`` (shipped inside this distribution) is the LaTeX notation
|
|
8
|
+
layer, licensed MPL-2.0 OR LPPL-1.3c. Use it directly with
|
|
9
|
+
``\\usepackage{sysml2tikz}`` if you only want figures in a paper.
|
|
10
|
+
* this Python package turns a SysML v2 model (or a notation IR) into a ``.tex``
|
|
11
|
+
file: ``project`` (sysmlpy -> IR), ``emit`` (IR -> .tex), ``layout``
|
|
12
|
+
(TeX-measured sizes -> routed coordinates).
|
|
13
|
+
|
|
14
|
+
Nothing here is required to use the ``.sty``, and the ``.sty`` is not required
|
|
15
|
+
to emit IR.
|
|
16
|
+
"""
|
|
17
|
+
from importlib.resources import files
|
|
18
|
+
|
|
19
|
+
__version__ = "0.0.1"
|
|
20
|
+
|
|
21
|
+
def sty_path() -> str:
|
|
22
|
+
"""Absolute path of the bundled ``sysml2tikz.sty`` (for TEXINPUTS)."""
|
|
23
|
+
return str(files(__name__).joinpath("sysml2tikz.sty"))
|
|
24
|
+
|
|
25
|
+
__all__ = ["__version__", "sty_path"]
|
sysml2tikz/cli.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
# Copyright (c) 2026 Jon R. Fox and Hermes Agent
|
|
3
|
+
"""Command line entry points.
|
|
4
|
+
|
|
5
|
+
sysml2tikz emit model.json out.tex IR -> .tex
|
|
6
|
+
sysml2tikz layout model.json out.tex IR -> measured + routed .tex
|
|
7
|
+
sysml2tikz project model.sysml out.json SysML v2 text -> IR
|
|
8
|
+
sysml2tikz sty print the bundled .sty path
|
|
9
|
+
sysml2tikz version
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _usage() -> int:
|
|
19
|
+
print(__doc__.strip())
|
|
20
|
+
return 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: list[str] | None = None) -> int:
|
|
24
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
25
|
+
if not argv:
|
|
26
|
+
return _usage()
|
|
27
|
+
cmd, rest = argv[0], argv[1:]
|
|
28
|
+
if cmd == "version":
|
|
29
|
+
print(__version__)
|
|
30
|
+
return 0
|
|
31
|
+
if cmd == "sty":
|
|
32
|
+
from . import sty_path
|
|
33
|
+
print(sty_path())
|
|
34
|
+
return 0
|
|
35
|
+
if cmd == "emit":
|
|
36
|
+
from .emit import main as run
|
|
37
|
+
return run(["emit"] + rest)
|
|
38
|
+
if cmd == "layout":
|
|
39
|
+
from .layout import main as run
|
|
40
|
+
return run(["layout"] + rest)
|
|
41
|
+
if cmd == "project":
|
|
42
|
+
from .project import main as run
|
|
43
|
+
return run(["project"] + rest)
|
|
44
|
+
if cmd in ("-h", "--help", "help"):
|
|
45
|
+
return _usage()
|
|
46
|
+
print("unknown subcommand: %s\n" % cmd, file=sys.stderr)
|
|
47
|
+
return _usage()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
raise SystemExit(main())
|
sysml2tikz/emit.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
# Copyright (c) 2026 Jon R. Fox and Hermes Agent
|
|
4
|
+
r"""emit_tex.py — reference implementation of stage 4 of the sysml2tikz pipeline.
|
|
5
|
+
|
|
6
|
+
Reads a small notation IR (JSON) and writes a TikZ .tex file that uses
|
|
7
|
+
sysml2tikz.sty. Deliberately dumb: no parsing, no layout engine, no model
|
|
8
|
+
semantics — those are stages 1-3. This exists to pin down the emitter
|
|
9
|
+
contract (row mini-syntax, escaping, deterministic ordering) and to prove the
|
|
10
|
+
IR is expressive enough for definition/usage figures.
|
|
11
|
+
|
|
12
|
+
Row mini-syntax (one string per compartment row):
|
|
13
|
+
"*perform* providePower" -> keyword in bold: \kw{perform} providePower
|
|
14
|
+
":>>Vehicle::mass" -> redefinition row: \redefines{:>>Vehicle::mass}
|
|
15
|
+
"mass :> mass" -> plain text, ':>' kept literally
|
|
16
|
+
"text = \"1800 kg\"" -> plain text, '=' kept literally
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python3 emit_tex.py model.json out.tex
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
# LaTeX specials that must not reach the output raw. Order matters: backslash
|
|
29
|
+
# first, then the rest. ':' '>' '=' '<' '"' are left alone on purpose — they
|
|
30
|
+
# are SysML notation, and the font encoding handles them.
|
|
31
|
+
_ESCAPE = {
|
|
32
|
+
"\\": r"\textbackslash{}",
|
|
33
|
+
"_": r"\_",
|
|
34
|
+
"&": r"\&",
|
|
35
|
+
"%": r"\%",
|
|
36
|
+
"#": r"\#",
|
|
37
|
+
"$": r"\$",
|
|
38
|
+
"~": r"\textasciitilde{}",
|
|
39
|
+
"^": r"\textasciicircum{}",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def esc(text: str) -> str:
|
|
44
|
+
out = []
|
|
45
|
+
for ch in text:
|
|
46
|
+
out.append(_ESCAPE.get(ch, ch))
|
|
47
|
+
return "".join(out)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_row(row: str) -> str:
|
|
51
|
+
"""Turn one IR row string into LaTeX source (no trailing line break)."""
|
|
52
|
+
row = row.strip()
|
|
53
|
+
if row.startswith(":>>"):
|
|
54
|
+
# '>>' is a ligature in T1/TU fonts and prints as »; break it explicitly
|
|
55
|
+
return r"\redefines{:\textgreater{}\textgreater{}%s}" % esc(row[3:])
|
|
56
|
+
# *keyword* anywhere in the row becomes \kw{keyword}
|
|
57
|
+
parts = re.split(r"\*([^*]+)\*", row)
|
|
58
|
+
out = []
|
|
59
|
+
for i, part in enumerate(parts):
|
|
60
|
+
out.append((r"\kw{%s}" % esc(part)) if i % 2 else esc(part))
|
|
61
|
+
return "".join(out)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_compartments(compartments) -> str:
|
|
65
|
+
bits = []
|
|
66
|
+
for comp in compartments:
|
|
67
|
+
bits.append(r"\sysmlcomp{%s}" % esc(comp["label"]))
|
|
68
|
+
for row in comp["rows"]:
|
|
69
|
+
bits.append(render_row(row) + r"\\")
|
|
70
|
+
# the final row keeps its \\ so the tabular closes cleanly
|
|
71
|
+
return "".join(bits)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def emit_node(node: dict) -> str:
|
|
75
|
+
kind = node["kind"]
|
|
76
|
+
place = node.get("place") # e.g. "at={(0,0)}" or "below=of veh"
|
|
77
|
+
opt = f"[{place}]" if place else "[at={(0,0)}]"
|
|
78
|
+
body = render_compartments(node.get("compartments", []))
|
|
79
|
+
if kind.endswith(" def"):
|
|
80
|
+
return (
|
|
81
|
+
" \\sysmldefnode%s{%s}{%s}{%s}{%%\n %s\n }\n"
|
|
82
|
+
% (opt, node["id"], esc(kind), esc(node["name"]), body)
|
|
83
|
+
)
|
|
84
|
+
# a usage: header carries "name : Type"
|
|
85
|
+
return (
|
|
86
|
+
" \\sysmlusagenode%s{%s}{%s}{%s}{%s}{%%\n %s\n }\n"
|
|
87
|
+
% (opt, node["id"], esc(kind), esc(node["name"]), esc(node.get("type", "")), body)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def emit_port(port: dict) -> str:
|
|
92
|
+
return " \\sysmlport{%s}{%s}{%s}{%s}\n" % (
|
|
93
|
+
port["node"],
|
|
94
|
+
port["side"],
|
|
95
|
+
esc(port["name"]),
|
|
96
|
+
port.get("label_side", "above"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def emit_edge(edge: dict) -> str:
|
|
101
|
+
style = edge.get("style", "reference")
|
|
102
|
+
src = edge["src"] if "src_port" not in edge else "%s-p-%s" % (edge["src"], edge["src_port"])
|
|
103
|
+
dst = edge["dst"] if "dst_port" not in edge else "%s-p-%s" % (edge["dst"], edge["dst_port"])
|
|
104
|
+
bits = [r" \draw[sysml %s] (%s)" % (style, src)]
|
|
105
|
+
route = edge.get("route")
|
|
106
|
+
if route == "arc-right":
|
|
107
|
+
bits.append(r" to[out=0, in=0, looseness=1.15]")
|
|
108
|
+
elif route == "arc-left":
|
|
109
|
+
bits.append(r" to[out=180, in=180, looseness=1.15]")
|
|
110
|
+
if route and route.startswith("bend-"):
|
|
111
|
+
bits.append(r" to[bend %s]" % route.split("-", 1)[1])
|
|
112
|
+
if not route:
|
|
113
|
+
bits.append(r" --")
|
|
114
|
+
label = edge.get("label")
|
|
115
|
+
if label:
|
|
116
|
+
bits.append(r" node[%s, font=\scriptsize, pos=0.5]{%s}"
|
|
117
|
+
% (edge.get("label_side", "right"), esc(label)))
|
|
118
|
+
bits.append(" (%s);\n" % dst)
|
|
119
|
+
return "".join(bits)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def emit(model: dict) -> str:
|
|
123
|
+
head = [
|
|
124
|
+
r"\documentclass[tikz,border=10pt]{standalone}",
|
|
125
|
+
r"\usepackage{sysml2tikz}",
|
|
126
|
+
r"\begin{document}",
|
|
127
|
+
r"\begin{tikzpicture}[node distance=%s]" % model.get("node_distance", "12mm"),
|
|
128
|
+
"",
|
|
129
|
+
]
|
|
130
|
+
body = []
|
|
131
|
+
for node in model.get("nodes", []):
|
|
132
|
+
body.append(emit_node(node))
|
|
133
|
+
body.append("")
|
|
134
|
+
for port in model.get("ports", []):
|
|
135
|
+
body.append(emit_port(port))
|
|
136
|
+
body.append("")
|
|
137
|
+
for edge in model.get("edges", []):
|
|
138
|
+
body.append(emit_edge(edge))
|
|
139
|
+
tail = [r"\end{tikzpicture}", r"\end{document}", ""]
|
|
140
|
+
return "\n".join(head + body + tail)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def copy_sty(dest_dir: str) -> str:
|
|
144
|
+
"""Drop the bundled .sty next to the output so any TeX engine finds it.
|
|
145
|
+
|
|
146
|
+
tectonic resolves packages from its bundle, not from TEXINPUTS, and a plain
|
|
147
|
+
TeX Live only finds sysml2tikz once CTAN has shipped it — so the reliable
|
|
148
|
+
move for a pip-only install is to put the file in the document's directory.
|
|
149
|
+
"""
|
|
150
|
+
from importlib.resources import files
|
|
151
|
+
import shutil
|
|
152
|
+
|
|
153
|
+
src = files("sysml2tikz").joinpath("sysml2tikz.sty")
|
|
154
|
+
dest = os.path.join(dest_dir, "sysml2tikz.sty")
|
|
155
|
+
with open(dest, "wb") as fh:
|
|
156
|
+
fh.write(src.read_bytes())
|
|
157
|
+
return dest
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main(argv: list[str]) -> int:
|
|
161
|
+
argv = [a for a in argv if a != "--copy-sty"]
|
|
162
|
+
want_sty = "--copy-sty" in sys.argv
|
|
163
|
+
if len(argv) != 3:
|
|
164
|
+
print(__doc__)
|
|
165
|
+
return 2
|
|
166
|
+
with open(argv[1], encoding="utf-8") as fh:
|
|
167
|
+
model = json.load(fh)
|
|
168
|
+
tex = emit(model)
|
|
169
|
+
with open(argv[2], "w", encoding="utf-8") as fh:
|
|
170
|
+
fh.write(tex)
|
|
171
|
+
if want_sty:
|
|
172
|
+
print("copied", copy_sty(os.path.dirname(os.path.abspath(argv[2]))))
|
|
173
|
+
print("wrote", argv[2], "(%d bytes, %d nodes, %d edges)"
|
|
174
|
+
% (len(tex), len(model.get("nodes", [])), len(model.get("edges", []))))
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
raise SystemExit(main(sys.argv))
|
sysml2tikz/layout.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
# Copyright (c) 2026 Jon R. Fox and Hermes Agent
|
|
4
|
+
"""layout_adapter.py — stage 3 of the sysml2tikz pipeline.
|
|
5
|
+
|
|
6
|
+
TikZ does not lay out graphs, and diagramboxes does not know how wide a TikZ
|
|
7
|
+
tabular will be. So: measure in TeX, lay out in diagramboxes, emit absolute
|
|
8
|
+
coordinates.
|
|
9
|
+
|
|
10
|
+
1. measure emit a throwaway .tex that places every block, then prints
|
|
11
|
+
width()/height() for each via \\typeout; compile with tectonic
|
|
12
|
+
--keep-logs and parse the log.
|
|
13
|
+
2. layout build a diagramboxes Diagram with those exact sizes (the
|
|
14
|
+
per-node `_calc_size` is neutered so the real size survives),
|
|
15
|
+
run its orthogonal router, read node origins and edge
|
|
16
|
+
waypoints.
|
|
17
|
+
3. emit write the real .tex with at={(x,y)} per node and one
|
|
18
|
+
\\draw ... -- (x,y) -- ... polyline per edge.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
python3 layout_adapter.py model.json out.tex [--scale 4.0]
|
|
22
|
+
|
|
23
|
+
`--scale` is points per diagramboxes unit. Sizes are divided by it going in
|
|
24
|
+
and multiplied coming out, so the router's gaps mean "scale points".
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- emit helpers
|
|
35
|
+
_ESCAPE = {
|
|
36
|
+
"\\": r"\textbackslash{}", "&": r"\&", "%": r"\%", "#": r"\#",
|
|
37
|
+
"$": r"\$", "~": r"\textasciitilde{}", "^": r"\textasciicircum{}",
|
|
38
|
+
"_": r"\_",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def esc(text: str) -> str:
|
|
43
|
+
return "".join(_ESCAPE.get(c, c) for c in text)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_row(row: str) -> str:
|
|
47
|
+
row = row.strip()
|
|
48
|
+
if row.startswith(":>>"):
|
|
49
|
+
# '>>' is a ligature in T1/TU fonts and prints as »; break it explicitly
|
|
50
|
+
return r"\redefines{:\textgreater{}\textgreater{}%s}" % esc(row[3:])
|
|
51
|
+
parts = re.split(r"\*([^*]+)\*", row)
|
|
52
|
+
out = []
|
|
53
|
+
for i, part in enumerate(parts):
|
|
54
|
+
out.append((r"\kw{%s}" % esc(part)) if i % 2 else esc(part))
|
|
55
|
+
return "".join(out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def node_body(node: dict) -> str:
|
|
59
|
+
bits = []
|
|
60
|
+
for comp in node.get("compartments", []):
|
|
61
|
+
bits.append(r"\sysmlcomp{%s}" % esc(comp["label"]))
|
|
62
|
+
for row in comp["rows"]:
|
|
63
|
+
bits.append(render_row(row) + r"\\")
|
|
64
|
+
return "".join(bits)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def node_call(node: dict, opt: str) -> str:
|
|
68
|
+
kind = node["kind"]
|
|
69
|
+
body = node_body(node)
|
|
70
|
+
if kind.endswith(" def"):
|
|
71
|
+
return (" \\sysmldefnode%s{%s}{%s}{%s}{%%\n %s\n }\n"
|
|
72
|
+
% (opt, node["id"], esc(kind), esc(node["name"]), body))
|
|
73
|
+
return (" \\sysmlusagenode%s{%s}{%s}{%s}{%s}{%%\n %s\n }\n"
|
|
74
|
+
% (opt, node["id"], esc(kind), esc(node["name"]),
|
|
75
|
+
esc(node.get("type", "")), body))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------- 1 measure
|
|
79
|
+
def measure(model: dict, workdir: str, tectonic: str) -> dict[str, tuple[float, float]]:
|
|
80
|
+
"""Return {node id: (width_pt, height_pt)} measured by TeX itself."""
|
|
81
|
+
nodes = model.get("nodes", [])
|
|
82
|
+
# spread them out so nothing overlaps while measuring
|
|
83
|
+
lines = [r"\documentclass[tikz,border=2pt,multi=false]{standalone}",
|
|
84
|
+
r"\usepackage{sysml2tikz}",
|
|
85
|
+
r"\begin{document}",
|
|
86
|
+
r"\begin{tikzpicture}[x=200cm,y=1cm]"]
|
|
87
|
+
for i, node in enumerate(nodes):
|
|
88
|
+
lines.append(node_call(node, "[at={(%.3fcm,0)}]" % i))
|
|
89
|
+
for node in nodes:
|
|
90
|
+
# measure by anchor difference: width("name") is fragile with quoting
|
|
91
|
+
lines.append(r" \newdimen\sysmlw\newdimen\sysmlh")
|
|
92
|
+
lines.append(r" \pgfextractx{\sysmlw}{\pgfpointdiff{\pgfpointanchor{%s}{west}}"
|
|
93
|
+
r"{\pgfpointanchor{%s}{east}}}" % (node["id"], node["id"]))
|
|
94
|
+
lines.append(r" \pgfextracty{\sysmlh}{\pgfpointdiff{\pgfpointanchor{%s}{south}}"
|
|
95
|
+
r"{\pgfpointanchor{%s}{north}}}" % (node["id"], node["id"]))
|
|
96
|
+
lines.append(r" \typeout{SYSMLSZ %s \the\sysmlw}" % node["id"])
|
|
97
|
+
lines.append(r" \typeout{SYSMLSH %s \the\sysmlh}" % node["id"])
|
|
98
|
+
lines += [r"\end{tikzpicture}", r"\end{document}", ""]
|
|
99
|
+
tex = os.path.join(workdir, "measure.tex")
|
|
100
|
+
with open(tex, "w", encoding="utf-8") as fh:
|
|
101
|
+
fh.write("\n".join(lines))
|
|
102
|
+
subprocess.run([tectonic, "--keep-logs", tex], cwd=workdir,
|
|
103
|
+
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
104
|
+
log = os.path.join(workdir, "measure.log")
|
|
105
|
+
sizes: dict[str, dict[str, float]] = {}
|
|
106
|
+
with open(log, encoding="utf-8", errors="replace") as fh:
|
|
107
|
+
for line in fh:
|
|
108
|
+
m = re.search(r"SYSMLSZ (\S+) ([\d.]+)", line)
|
|
109
|
+
if m:
|
|
110
|
+
sizes.setdefault(m.group(1), {})["w"] = float(m.group(2))
|
|
111
|
+
m = re.search(r"SYSMLSH (\S+) ([\d.]+)", line)
|
|
112
|
+
if m:
|
|
113
|
+
sizes.setdefault(m.group(1), {})["h"] = float(m.group(2))
|
|
114
|
+
missing = [n["id"] for n in nodes if n["id"] not in sizes]
|
|
115
|
+
if missing:
|
|
116
|
+
raise SystemExit("measure failed for: %s" % ", ".join(missing))
|
|
117
|
+
return {k: (v["w"], v["h"]) for k, v in sizes.items()}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# -------------------------------------------------------------------- 2 layout
|
|
121
|
+
def layout(model: dict, sizes: dict[str, tuple[float, float]], scale: float,
|
|
122
|
+
layer_gap: int, node_gap: int):
|
|
123
|
+
try:
|
|
124
|
+
from diagramboxes.layout import Diagram
|
|
125
|
+
except ImportError: # dev checkout without an install
|
|
126
|
+
sys.path.insert(0, os.path.expanduser("~/proj/diagramboxes"))
|
|
127
|
+
try:
|
|
128
|
+
from diagramboxes.layout import Diagram
|
|
129
|
+
except ImportError as exc:
|
|
130
|
+
raise SystemExit(
|
|
131
|
+
"layout needs diagramboxes (pip install 'sysml2tikz[layout]'): %s" % exc)
|
|
132
|
+
|
|
133
|
+
d = Diagram()
|
|
134
|
+
by_id = {}
|
|
135
|
+
for node in model.get("nodes", []):
|
|
136
|
+
n = d.add_node(node["id"])
|
|
137
|
+
n._calc_size = lambda: None # keep the TeX size, do not re-derive
|
|
138
|
+
w, h = sizes[node["id"]]
|
|
139
|
+
# diagramboxes' router works in integer pixel units
|
|
140
|
+
n.w, n.h = max(1, int(round(w / scale))), max(1, int(round(h / scale)))
|
|
141
|
+
by_id[node["id"]] = n
|
|
142
|
+
# ports: IR side names -> diagramboxes sides; the router docks on them
|
|
143
|
+
side_map = {"east": "right", "west": "left", "north": "top", "south": "bottom"}
|
|
144
|
+
ports: dict[tuple[str, str], object] = {}
|
|
145
|
+
for port in model.get("ports", []):
|
|
146
|
+
node = by_id[port["node"]]
|
|
147
|
+
db_side = side_map[port["side"]]
|
|
148
|
+
ports[(port["node"], port["side"])] = node.add_port(
|
|
149
|
+
port["name"], side=db_side, offset=0.5,
|
|
150
|
+
direction=port.get("direction"))
|
|
151
|
+
for edge in model.get("edges", []):
|
|
152
|
+
kw = {}
|
|
153
|
+
if edge.get("src_port"):
|
|
154
|
+
kw["source_port"] = ports[(edge["src"], edge["src_port"])]
|
|
155
|
+
if edge.get("dst_port"):
|
|
156
|
+
kw["target_port"] = ports[(edge["dst"], edge["dst_port"])]
|
|
157
|
+
d.add_edge(by_id[edge["src"]], by_id[edge["dst"]], **kw)
|
|
158
|
+
d.layout(routing="orthogonal", layer_gap=layer_gap, node_gap=node_gap)
|
|
159
|
+
return d, by_id, ports
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------- 3 emit
|
|
163
|
+
def label_at(points: list[tuple[float, float]]) -> tuple[float, float, float]:
|
|
164
|
+
"""Label position + the angle of the segment carrying it (longest segment)."""
|
|
165
|
+
best, best_len = None, -1.0
|
|
166
|
+
for (x1, y1), (x2, y2) in zip(points, points[1:]):
|
|
167
|
+
length = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
|
|
168
|
+
if length > best_len:
|
|
169
|
+
best, best_len = ((x1 + x2) / 2.0, (y1 + y2) / 2.0), length
|
|
170
|
+
return best[0], best[1], best_len
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def emit(model: dict, d, by_id, ports, scale: float, out_path: str) -> None:
|
|
174
|
+
geometry = {"nodes": {}, "ports": {}}
|
|
175
|
+
lines = [r"\documentclass[tikz,border=10pt]{standalone}",
|
|
176
|
+
r"\usepackage{sysml2tikz}",
|
|
177
|
+
r"\begin{document}",
|
|
178
|
+
r"\begin{tikzpicture}"]
|
|
179
|
+
for node in model.get("nodes", []):
|
|
180
|
+
n = by_id[node["id"]]
|
|
181
|
+
cx = (n.x + n.w / 2.0) * scale
|
|
182
|
+
cy = -(n.y + n.h / 2.0) * scale
|
|
183
|
+
lines.append(node_call(node, "[at={(%.1fpt,%.1fpt)}]" % (cx, cy)))
|
|
184
|
+
geometry["nodes"][node["id"]] = {
|
|
185
|
+
"cx": cx, "cy": cy,
|
|
186
|
+
"w": by_id[node["id"]].w * scale, "h": by_id[node["id"]].h * scale}
|
|
187
|
+
for i, edge in enumerate(model.get("edges", [])):
|
|
188
|
+
db_edge = d.edges[i]
|
|
189
|
+
pts = [((x) * scale, -(y) * scale) for x, y in db_edge.waypoints]
|
|
190
|
+
if not pts:
|
|
191
|
+
continue
|
|
192
|
+
style = edge.get("style", "reference")
|
|
193
|
+
coords = " -- ".join("(%.1fpt,%.1fpt)" % p for p in pts)
|
|
194
|
+
label = edge.get("label")
|
|
195
|
+
if label:
|
|
196
|
+
lx, ly, _ = label_at(db_edge.waypoints)
|
|
197
|
+
lines.append(r" \draw[sysml %s] %s;"
|
|
198
|
+
% (style, coords.replace(
|
|
199
|
+
" -- ", " -- ", 1)))
|
|
200
|
+
lines.append(r" \node[font=\scriptsize, right] at (%.1fpt,%.1fpt) {%s};"
|
|
201
|
+
% (lx * scale, -ly * scale, esc(label)))
|
|
202
|
+
else:
|
|
203
|
+
lines.append(r" \draw[sysml %s] %s;" % (style, coords))
|
|
204
|
+
# diagramboxes parks a port just OUTSIDE the node wall (x = px + pw); the
|
|
205
|
+
# corpus draws the square centred ON the wall. Slide the glyph back by half
|
|
206
|
+
# the router's own port box, so glyph centre == wall and the routed flow
|
|
207
|
+
# still terminates on the glyph's outer face.
|
|
208
|
+
for port in model.get("ports", []):
|
|
209
|
+
db_port = ports[(port["node"], port["side"])]
|
|
210
|
+
px = (db_port.x + db_port.w / 2.0) * scale
|
|
211
|
+
py = -(db_port.y + db_port.h / 2.0) * scale
|
|
212
|
+
side = port["side"]
|
|
213
|
+
if side == "east":
|
|
214
|
+
px -= (db_port.w / 2.0) * scale
|
|
215
|
+
elif side == "west":
|
|
216
|
+
px += (db_port.w / 2.0) * scale
|
|
217
|
+
elif side == "north":
|
|
218
|
+
py -= (db_port.h / 2.0) * scale
|
|
219
|
+
elif side == "south":
|
|
220
|
+
py += (db_port.h / 2.0) * scale
|
|
221
|
+
lines.append(" \\sysmlportat{%s-p-%s}{(%.1fpt,%.1fpt)}{%s}{%s}"
|
|
222
|
+
% (port["node"], port["side"], px, py, esc(port["name"]),
|
|
223
|
+
port.get("label_side", "above right")))
|
|
224
|
+
geometry["ports"]["%s.%s" % (port["node"], port["side"])] = {
|
|
225
|
+
"x": px, "y": py, "side": port["side"]}
|
|
226
|
+
lines += [r"\end{tikzpicture}", r"\end{document}", ""]
|
|
227
|
+
with open(out_path, "w", encoding="utf-8") as fh:
|
|
228
|
+
fh.write("\n".join(lines))
|
|
229
|
+
stem = os.path.splitext(os.path.basename(out_path))[0]
|
|
230
|
+
sidecar = os.path.join(os.path.dirname(os.path.abspath(out_path)),
|
|
231
|
+
"geometry-%s.json" % stem)
|
|
232
|
+
with open(sidecar, "w", encoding="utf-8") as fh:
|
|
233
|
+
json.dump(geometry, fh, indent=2, sort_keys=True)
|
|
234
|
+
print("wrote", out_path, "and", sidecar)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main(argv: list[str]) -> int:
|
|
238
|
+
if len(argv) < 3:
|
|
239
|
+
print(__doc__)
|
|
240
|
+
return 2
|
|
241
|
+
want_sty = "--copy-sty" in sys.argv
|
|
242
|
+
argv = [a for a in argv if a != "--copy-sty"]
|
|
243
|
+
model_path, out_path = argv[1], argv[2]
|
|
244
|
+
scale = 4.0
|
|
245
|
+
layer_gap, node_gap = 14, 8
|
|
246
|
+
for i, arg in enumerate(argv[3:]):
|
|
247
|
+
if arg == "--scale":
|
|
248
|
+
scale = float(argv[3 + i + 1])
|
|
249
|
+
with open(model_path, encoding="utf-8") as fh:
|
|
250
|
+
model = json.load(fh)
|
|
251
|
+
workdir = os.path.dirname(os.path.abspath(out_path))
|
|
252
|
+
tectonic = os.path.expanduser("~/.local/bin/tectonic")
|
|
253
|
+
sizes = measure(model, workdir, tectonic)
|
|
254
|
+
print("measured:", {k: (round(v[0], 1), round(v[1], 1)) for k, v in sizes.items()})
|
|
255
|
+
d, by_id, ports = layout(model, sizes, scale, layer_gap, node_gap)
|
|
256
|
+
emit(model, d, by_id, ports, scale, out_path)
|
|
257
|
+
if want_sty:
|
|
258
|
+
from .emit import copy_sty
|
|
259
|
+
print("copied", copy_sty(os.path.dirname(os.path.abspath(out_path))))
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
raise SystemExit(main(sys.argv))
|
sysml2tikz/project.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
# Copyright (c) 2026 Jon R. Fox and Hermes Agent
|
|
4
|
+
"""from_sysml.py — stage 1+2 of the sysml2tikz pipeline.
|
|
5
|
+
|
|
6
|
+
sysmlpy model -> notation IR (the JSON the emitter consumes).
|
|
7
|
+
|
|
8
|
+
Scope of this first cut, stated plainly:
|
|
9
|
+
* top-level definitions and usages (package children, one level deep) become
|
|
10
|
+
BOXES; their members become compartment ROWS, not separate boxes.
|
|
11
|
+
* a row is the member's own SysML text (`dump()`), which already carries the
|
|
12
|
+
notation the corpus prints: `:>` subsetting, `:>>Owner::x` redefinition,
|
|
13
|
+
`= value`, `[mult]`.
|
|
14
|
+
* keyword = the element's SysML kind + " def" for definitions
|
|
15
|
+
(`part def` / `part`, `attribute def` / `attribute`, ...).
|
|
16
|
+
* compartment label follows the corpus: bare noun on definitions
|
|
17
|
+
(`attributes`, `ports`), verb + noun on usages (`perform actions`,
|
|
18
|
+
`exhibit states`).
|
|
19
|
+
* one composition edge is emitted for a top-level usage whose type resolves
|
|
20
|
+
to another top-level definition.
|
|
21
|
+
|
|
22
|
+
Not handled yet (deliberate): nested part trees as nodes, ports as routing
|
|
23
|
+
anchors taken from the model, requirement satisfy edges from the model,
|
|
24
|
+
view/viewpoint frames.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
PYTHONPATH=~/proj/sysmlpy/src python3 from_sysml.py model.sysml out.json
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
KIND = {
|
|
39
|
+
"Part": "part", "Attribute": "attribute", "Port": "port", "Item": "item",
|
|
40
|
+
"Action": "action", "State": "state", "Requirement": "requirement",
|
|
41
|
+
"Calculation": "calc", "Constraint": "constraint", "Allocation": "allocation",
|
|
42
|
+
"Interface": "interface", "Enumeration": "enum", "Connection": "connection",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# class -> compartment label for a DEFINITION, and for a USAGE
|
|
46
|
+
COMPARTMENT = {
|
|
47
|
+
"Attribute": ("attributes", "attributes"),
|
|
48
|
+
"Part": ("parts", "parts"),
|
|
49
|
+
"Port": ("ports", "ports"),
|
|
50
|
+
"Item": ("items", "items"),
|
|
51
|
+
"Action": ("actions", "perform actions"),
|
|
52
|
+
"State": ("states", "exhibit states"),
|
|
53
|
+
"Requirement": ("requirements", "require requirements"),
|
|
54
|
+
"Calculation": ("calculations", "perform calculations"),
|
|
55
|
+
"Constraint": ("constraints", "assert constraints"),
|
|
56
|
+
"Connection": ("connections", "connections"),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
HEADER_RE = re.compile(
|
|
60
|
+
r"^\s*(?:\w+\s+(?:def\s+)?)?" # keyword, optionally 'def'
|
|
61
|
+
r"(?P<name>[A-Za-z_][\w]*(?:::[A-Za-z_][\w]*)?)" # name or qualified name
|
|
62
|
+
r"\s*(?::\s*(?P<type>[A-Za-z_][\w:]*(?:\[[^\]]*\])?))?" # optional : Type
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def first_line(text: str) -> str:
|
|
67
|
+
return text.strip().splitlines()[0] if text.strip() else ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
ROW_KEYWORD_RE = re.compile(
|
|
71
|
+
r"^(?:abstract\s+)?(?:part|attribute|port|item|action|state|requirement|"
|
|
72
|
+
r"calc|constraint|allocation|interface|connection|enum|ref)\s+(?:def\s+)?")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dump_row(elem) -> str:
|
|
76
|
+
"""A member's SysML text as one notation row.
|
|
77
|
+
|
|
78
|
+
The corpus prints compartment rows WITHOUT the member's own keyword
|
|
79
|
+
("power : PowerValue", not "attribute power : PowerValue"), so the leading
|
|
80
|
+
keyword is stripped — the compartment label already carries it.
|
|
81
|
+
"""
|
|
82
|
+
text = elem.dump().strip()
|
|
83
|
+
text = " ".join(line.strip() for line in text.splitlines())
|
|
84
|
+
text = text.rstrip(";").split("{")[0].strip()
|
|
85
|
+
return ROW_KEYWORD_RE.sub("", text)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def classify(elem):
|
|
89
|
+
cls = type(elem).__name__
|
|
90
|
+
return KIND.get(cls), COMPARTMENT.get(cls)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
skipped: list[str] = []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def project_element(elem) -> dict | None:
|
|
97
|
+
kind, compartments = classify(elem)
|
|
98
|
+
if kind is None:
|
|
99
|
+
return None
|
|
100
|
+
is_def = bool(getattr(elem, "is_definition", False))
|
|
101
|
+
header = first_line(elem.dump())
|
|
102
|
+
m = HEADER_RE.match(header)
|
|
103
|
+
if not m or not m.group("name"):
|
|
104
|
+
return None
|
|
105
|
+
node = {
|
|
106
|
+
"id": re.sub(r"\W+", "_", m.group("name")),
|
|
107
|
+
"kind": kind + (" def" if is_def else ""),
|
|
108
|
+
"name": m.group("name"),
|
|
109
|
+
}
|
|
110
|
+
type_field = m.group("type")
|
|
111
|
+
if type_field and not is_def:
|
|
112
|
+
node["type"] = type_field
|
|
113
|
+
# members -> rows, grouped into corpus-ordered compartments
|
|
114
|
+
order = ["attributes", "parts", "ports", "actions", "states",
|
|
115
|
+
"requirements", "items", "connections", "calculations", "constraints"]
|
|
116
|
+
buckets: dict[str, list[str]] = {}
|
|
117
|
+
for child in getattr(elem, "children", []) or []:
|
|
118
|
+
ckind, ccomp = classify(child)
|
|
119
|
+
if ckind is None or ccomp is None:
|
|
120
|
+
continue
|
|
121
|
+
label = ccomp[0] if is_def else ccomp[1]
|
|
122
|
+
row = dump_row(child)
|
|
123
|
+
if row.strip() in ("action", "perform", "perform action"):
|
|
124
|
+
# sysmlpy keeps no name for `perform x;` (dump() -> "perform action ;")
|
|
125
|
+
skipped.append("%s: %s" % (type(child).__name__, elem.dump().split()[0]))
|
|
126
|
+
continue
|
|
127
|
+
if is_def and type(child).__name__ == "Action" and row:
|
|
128
|
+
row = "*perform* " + row
|
|
129
|
+
if not row:
|
|
130
|
+
continue
|
|
131
|
+
buckets.setdefault(label, []).append(row)
|
|
132
|
+
node["compartments"] = [
|
|
133
|
+
{"label": label, "rows": buckets[label]}
|
|
134
|
+
for label in sorted(buckets, key=lambda l: order.index(l) if l in order else 99)
|
|
135
|
+
]
|
|
136
|
+
return node
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def project(model) -> dict:
|
|
140
|
+
nodes: list[dict] = []
|
|
141
|
+
types: dict[str, str] = {} # definition name -> node id
|
|
142
|
+
usages: list[tuple[str, str, str | None]] = [] # (node id, type name, mult)
|
|
143
|
+
|
|
144
|
+
for pkg in getattr(model, "children", []) or []:
|
|
145
|
+
for elem in getattr(pkg, "children", []) or []:
|
|
146
|
+
node = project_element(elem)
|
|
147
|
+
if node is None:
|
|
148
|
+
continue
|
|
149
|
+
nodes.append(node)
|
|
150
|
+
if node["kind"].endswith(" def"):
|
|
151
|
+
types[node["name"]] = node["id"]
|
|
152
|
+
elif node.get("type"):
|
|
153
|
+
mult = re.search(r"\[([^\]]+)\]", node["type"])
|
|
154
|
+
base = re.sub(r"\s*\[[^\]]*\]", "", node["type"]).strip()
|
|
155
|
+
usages.append((node["id"], base, mult.group(1) if mult else None))
|
|
156
|
+
|
|
157
|
+
edges = []
|
|
158
|
+
for node_id, type_name, mult in usages:
|
|
159
|
+
target = types.get(type_name.split("::")[-1])
|
|
160
|
+
if target:
|
|
161
|
+
edge = {"src": node_id, "dst": target, "style": "composition"}
|
|
162
|
+
if mult:
|
|
163
|
+
edge["label"] = mult
|
|
164
|
+
edges.append(edge)
|
|
165
|
+
|
|
166
|
+
# keep definitions above their usages
|
|
167
|
+
out_nodes = sorted(nodes, key=lambda n: (not n["kind"].endswith(" def"), n["id"]))
|
|
168
|
+
ir = {"node_distance": "14mm", "nodes": out_nodes, "ports": [], "edges": edges}
|
|
169
|
+
if skipped:
|
|
170
|
+
ir["projection_notes"] = {
|
|
171
|
+
"skipped_members": sorted(set(skipped)),
|
|
172
|
+
"why": "sysmlpy does not retain a name for these members (e.g. a bare "
|
|
173
|
+
"`perform x;` parses to 'perform action ;'), so there is no row to print",
|
|
174
|
+
}
|
|
175
|
+
return ir
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main(argv: list[str]) -> int:
|
|
179
|
+
if len(argv) != 3:
|
|
180
|
+
print(__doc__)
|
|
181
|
+
return 2
|
|
182
|
+
try:
|
|
183
|
+
import sysmlpy
|
|
184
|
+
except ImportError: # dev checkout without an install
|
|
185
|
+
sys.path.insert(0, os.path.expanduser("~/proj/sysmlpy/src"))
|
|
186
|
+
try:
|
|
187
|
+
import sysmlpy
|
|
188
|
+
except ImportError as exc:
|
|
189
|
+
raise SystemExit(
|
|
190
|
+
"project needs sysmlpy (pip install 'sysml2tikz[parse]'): %s" % exc)
|
|
191
|
+
|
|
192
|
+
with open(argv[1], encoding="utf-8") as fh:
|
|
193
|
+
model = sysmlpy.loads(fh.read())
|
|
194
|
+
ir = project(model)
|
|
195
|
+
with open(argv[2], "w", encoding="utf-8") as fh:
|
|
196
|
+
json.dump(ir, fh, indent=2)
|
|
197
|
+
print("projected %d node(s), %d edge(s) -> %s"
|
|
198
|
+
% (len(ir["nodes"]), len(ir["edges"]), argv[2]))
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
raise SystemExit(main(sys.argv))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
% SPDX-License-Identifier: MPL-2.0 OR LPPL-1.3c
|
|
2
|
+
% Copyright (c) 2026 Jon R. Fox and Hermes Agent
|
|
3
|
+
%
|
|
4
|
+
% This file may be used under either of two licences, at your option:
|
|
5
|
+
% * the LaTeX Project Public License, version 1.3c or later (LICENSES/LPPL-1.3c.txt)
|
|
6
|
+
% — the branch under which the package can live on CTAN and in TeX Live;
|
|
7
|
+
% * the Mozilla Public License, version 2.0 (LICENSES/MPL-2.0.txt).
|
|
8
|
+
%
|
|
9
|
+
% sysml2tikz.sty — a SysML v2 notation layer on plain TikZ.
|
|
10
|
+
%
|
|
11
|
+
% Grounded in the OMG SysML v2.1 reference-implementation figures
|
|
12
|
+
% (SysML-v2-Release/doc/images/sysml/): fig-57 "Part Definition for Vehicle"
|
|
13
|
+
% (square-cornered definition, keyword + name header, italic centred
|
|
14
|
+
% compartment labels) and fig-61 "Part Usage for vehicle_b" (ROUNDED corners,
|
|
15
|
+
% "name: Type" header, ':>>' redefinition rows, 'default' / '=' value rows,
|
|
16
|
+
% verb-prefixed compartment labels such as "perform actions").
|
|
17
|
+
%
|
|
18
|
+
% Distinctions this file encodes:
|
|
19
|
+
% definition : square corners, header = keyword + name, compartments are
|
|
20
|
+
% bare nouns (attributes, ports, actions, states)
|
|
21
|
+
% usage : rounded corners, header = keyword + "name: Type", compartment
|
|
22
|
+
% labels keep the verb (perform actions, exhibit states) and
|
|
23
|
+
% rows may carry ':>>Owner::feature', 'default', '=' bindings
|
|
24
|
+
%
|
|
25
|
+
% Author: Jon R. Fox + Hermes Agent. Licence: MPL-2.0.
|
|
26
|
+
\NeedsTeXFormat{LaTeX2e}
|
|
27
|
+
\ProvidesPackage{sysml2tikz}[2026/11/11 v0.1 SysML v2 notation for TikZ]
|
|
28
|
+
|
|
29
|
+
\RequirePackage{tikz}
|
|
30
|
+
\usetikzlibrary{arrows.meta,positioning,calc,fit,shapes.geometric}
|
|
31
|
+
|
|
32
|
+
% ---------------------------------------------------------------- dimensions
|
|
33
|
+
\newlength{\sysmlrowsep}
|
|
34
|
+
\setlength{\sysmlrowsep}{2pt}
|
|
35
|
+
\newlength{\sysmlradius}
|
|
36
|
+
\setlength{\sysmlradius}{4.5pt}
|
|
37
|
+
|
|
38
|
+
% ------------------------------------------------------------------- styles
|
|
39
|
+
\tikzset{
|
|
40
|
+
% a definition: square corners (corpus fig-57)
|
|
41
|
+
sysml def box/.style={
|
|
42
|
+
draw, thick, fill=white, rectangle, rounded corners=0pt,
|
|
43
|
+
inner sep=2.6pt, align=left, font=\footnotesize\sffamily
|
|
44
|
+
},
|
|
45
|
+
% a usage: rounded corners (corpus fig-61)
|
|
46
|
+
sysml usage box/.style={
|
|
47
|
+
draw, thick, fill=white, rectangle, rounded corners=\sysmlradius,
|
|
48
|
+
inner sep=2.6pt, align=left, font=\footnotesize\sffamily
|
|
49
|
+
},
|
|
50
|
+
sysml frame/.style={
|
|
51
|
+
draw, thick, fill=white, rectangle, inner sep=10pt, font=\footnotesize\sffamily
|
|
52
|
+
},
|
|
53
|
+
sysml port/.style={
|
|
54
|
+
draw, fill=white, thick, rectangle, minimum size=2.4mm, inner sep=0pt
|
|
55
|
+
},
|
|
56
|
+
sysml edge/.style={thick, draw=black},
|
|
57
|
+
% composition (whole end carries the filled diamond)
|
|
58
|
+
sysml composition/.style={sysml edge, -{Diamond[length=3.2mm,width=2.4mm,fill=black]}},
|
|
59
|
+
% directed reference / item flow
|
|
60
|
+
sysml reference/.style={sysml edge, -{Stealth[length=2.6mm]}},
|
|
61
|
+
sysml itemflow/.style={sysml edge, dashed, -{Stealth[length=2.4mm]}},
|
|
62
|
+
% satisfy / dependency
|
|
63
|
+
sysml satisfy/.style={sysml edge, densely dotted, -{Stealth[length=2.4mm]}},
|
|
64
|
+
sysml gen/.style={sysml edge, -{Triangle[length=3mm,width=3mm,open,fill=white]}},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
% ------------------------------------------------------------- header pieces
|
|
68
|
+
% «keyword» — italic inside guillemets, as printed in the corpus
|
|
69
|
+
\newcommand{\sysmlkw}[1]{\textit{«#1»}}
|
|
70
|
+
% definition header: keyword line, then centred name, then a rule
|
|
71
|
+
\newcommand{\sysmlhead}[2]{%
|
|
72
|
+
\multicolumn{1}{c}{\sysmlkw{#1}}\\[-\sysmlrowsep]
|
|
73
|
+
\multicolumn{1}{c}{\large\sffamily #2}\\[1.5pt]}
|
|
74
|
+
% usage header: keyword line, then centred "name : Type", then a rule
|
|
75
|
+
\newcommand{\sysmlheadusage}[2]{%
|
|
76
|
+
\multicolumn{1}{c}{\sysmlkw{#1}}\\[-\sysmlrowsep]
|
|
77
|
+
\multicolumn{1}{c}{\large\sffamily #2}\\[1.5pt]}
|
|
78
|
+
% compartment label: rule + centred italic label
|
|
79
|
+
\newcommand{\sysmlcomp}[1]{\hline\multicolumn{1}{c}{\itshape #1}\\[-\sysmlrowsep]}
|
|
80
|
+
% member keyword (perform, exhibit, default, allocate ...) — bold in corpus
|
|
81
|
+
\newcommand{\kw}[1]{\textbf{#1}}
|
|
82
|
+
% a redefinition row, printed italic in the corpus: ':>>Vehicle::mass'
|
|
83
|
+
\newcommand{\redefines}[1]{\textit{#1}}
|
|
84
|
+
|
|
85
|
+
% --------------------------------------------------------------- block nodes
|
|
86
|
+
% \sysmldefnode[<tikz placement opts>]{<node id>}{<kind>}{<name>}{<body>}
|
|
87
|
+
\newcommand{\sysmldefnode}[5][]{%
|
|
88
|
+
\node[sysml def box, #1] (#2) {\begin{tabular}{@{}l@{}}%
|
|
89
|
+
\sysmlhead{#3}{#4}#5%
|
|
90
|
+
\end{tabular}};}
|
|
91
|
+
|
|
92
|
+
% \sysmlusagenode[<tikz placement opts>]{<id>}{<kind>}{<name>}{<type>}{<body>}
|
|
93
|
+
\newcommand{\sysmlusagenode}[6][]{%
|
|
94
|
+
\node[sysml usage box, #1] (#2) {\begin{tabular}{@{}l@{}}%
|
|
95
|
+
\sysmlheadusage{#3}{#4\,:\,#5}#6%
|
|
96
|
+
\end{tabular}};}
|
|
97
|
+
|
|
98
|
+
% ---------------------------------------------------------------- ports
|
|
99
|
+
% \sysmlport{<node>}{<anchor or angle>}{<label>}{<label side>}
|
|
100
|
+
\newcommand{\sysmlport}[4]{%
|
|
101
|
+
\node[sysml port, label={[font=\scriptsize\sffamily, inner sep=1pt]#4:#3}]
|
|
102
|
+
(#1-p-#2) at (#1.#2) {};}
|
|
103
|
+
|
|
104
|
+
% \sysmlportat{<node id>}{(x,y)}{<label>}{<label anchor>} — used by the layout
|
|
105
|
+
% adapter, which knows the routed dock coordinates instead of a bare side.
|
|
106
|
+
\newcommand{\sysmlportat}[4]{%
|
|
107
|
+
\node[sysml port, label={[font=\scriptsize\sffamily, inner sep=1pt]#4:#3}]
|
|
108
|
+
(#1) at #2 {};}
|
|
109
|
+
|
|
110
|
+
% ---------------------------------------------------------------- framing
|
|
111
|
+
% \sysmlframe{<id>}{<title>}{<fit targets>}
|
|
112
|
+
\newcommand{\sysmlframenode}[3]{%
|
|
113
|
+
\node[sysml frame, fit=#3, label={[font=\scriptsize\sffamily,anchor=north]north:{#2}}] (#1) {};}
|
|
114
|
+
|
|
115
|
+
\endinput
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sysml2tikz
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: SysML v2 notation for LaTeX/TikZ: a .sty notation layer plus a model-to-figure pipeline
|
|
5
|
+
Project-URL: Homepage, https://github.com/mycr0ft/sysml2tikz
|
|
6
|
+
Project-URL: Issues, https://github.com/mycr0ft/sysml2tikz/issues
|
|
7
|
+
Author: Jon R. Fox
|
|
8
|
+
License: MPL-2.0 OR LPPL-1.3c
|
|
9
|
+
Keywords: diagram,latex,mbse,sysml,sysml-v2,tikz,uml
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Documentation
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering
|
|
15
|
+
Classifier: Topic :: Text Processing :: Markup :: LaTeX
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Provides-Extra: layout
|
|
18
|
+
Requires-Dist: diagramboxes; extra == 'layout'
|
|
19
|
+
Provides-Extra: parse
|
|
20
|
+
Requires-Dist: sysmlpy; extra == 'parse'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# tikz-sysml — SysML v2 notation in TikZ
|
|
24
|
+
|
|
25
|
+
Two things live here:
|
|
26
|
+
|
|
27
|
+
1. `sysml2tikz.sty` — a notation layer over plain TikZ. It encodes the
|
|
28
|
+
definition/usage distinction the way the OMG SysML v2 reference figures draw
|
|
29
|
+
it, and gives you compartments, ports, item flows and the relationship
|
|
30
|
+
arrowheads without hand-drawing any of it.
|
|
31
|
+
2. `gen/` — the pipeline: `from_sysml.py` (sysmlpy → IR), `emit_tex.py` (IR →
|
|
32
|
+
.tex), `layout_adapter.py` (TeX-measured sizes → diagramboxes' orthogonal
|
|
33
|
+
router → absolute coordinates), `tools/verify.py` (golden diff, compile,
|
|
34
|
+
geometry gates). See `PIPELINE.md`.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Two independent doors:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# LaTeX only — drop the .sty in your document's directory (or install it from CTAN
|
|
42
|
+
# once the package is accepted), then:
|
|
43
|
+
\usepackage{sysml2tikz}
|
|
44
|
+
|
|
45
|
+
# Python — the generator; the wheel carries the same .sty
|
|
46
|
+
pip install sysml2tikz
|
|
47
|
+
sysml2tikz emit model.json out.tex --copy-sty # --copy-sty puts the .sty beside the output
|
|
48
|
+
tectonic out.tex
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`--copy-sty` exists because tectonic resolves packages from its own bundle rather
|
|
52
|
+
than from `TEXINPUTS`, and a plain TeX Live only finds `sysml2tikz.sty` after CTAN
|
|
53
|
+
ships it. For a repo checkout the `gen/*.py` scripts are thin shims over
|
|
54
|
+
`src/sysml2tikz/*`, so the commands in `PIPELINE.md` keep working with no install.
|
|
55
|
+
|
|
56
|
+
Optional extras: `pip install "sysml2tikz[parse]"` (sysmlpy, for SysML text input)
|
|
57
|
+
and `pip install "sysml2tikz[layout]"` (diagramboxes, for routed layout).
|
|
58
|
+
|
|
59
|
+
## Build
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
tectonic demo.tex # hand-authored API demo
|
|
63
|
+
cd gen && python3 emit_tex.py model.json out.tex && tectonic out.tex
|
|
64
|
+
pdftoppm -r 190 -png -singlefile out.pdf out # review raster
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
No system TeX required: `tectonic` fetches only what a document uses.
|
|
68
|
+
|
|
69
|
+
## Figures
|
|
70
|
+
|
|
71
|
+
All four are produced by this repo; the PNGs are 200 dpi rasters of the compiled PDFs.
|
|
72
|
+
|
|
73
|
+
Hand-authored macros — the definition/usage distinction in one picture
|
|
74
|
+
(`demo.tex`): square «part def», rounded «part», compartments, ports, composition,
|
|
75
|
+
«satisfy».
|
|
76
|
+
|
|
77
|
+

|
|
78
|
+
|
|
79
|
+
Same figure generated from a JSON notation IR (`gen/model.json` → `gen/emit_tex.py`) —
|
|
80
|
+
nothing typed by hand:
|
|
81
|
+
|
|
82
|
+

|
|
83
|
+
|
|
84
|
+
Laid out automatically: TeX-measured box sizes → diagramboxes' orthogonal router →
|
|
85
|
+
absolute coordinates and polyline edges (`gen/laid_out.tex`):
|
|
86
|
+
|
|
87
|
+

|
|
88
|
+
|
|
89
|
+
End to end from SysML v2 text — `examples/vehicle.sysml` parsed by sysmlpy, projected,
|
|
90
|
+
routed and rendered:
|
|
91
|
+
|
|
92
|
+

|
|
93
|
+
|
|
94
|
+
## The distinction this package exists for
|
|
95
|
+
|
|
96
|
+
The corpus draws the two sides differently and the difference is semantic, not
|
|
97
|
+
decoration:
|
|
98
|
+
|
|
99
|
+
| | definition | usage |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| keyword | `«part def»` | `«part»` |
|
|
102
|
+
| box | square corners | rounded corners |
|
|
103
|
+
| header | name only | `name : Type` |
|
|
104
|
+
| compartment labels | bare noun (`attributes`, `ports`, `actions`, `states`) | verb + noun (`perform actions`, `exhibit states`) |
|
|
105
|
+
| rows | `max :> temperature` | `cargoMass default 0 [kg]`, `:>>Vehicle::cargoMass`, `dryMass=sum(partMasses)` |
|
|
106
|
+
|
|
107
|
+
Grounded in `fig-57-Part-Definition-for-Vehicle.svg` and
|
|
108
|
+
`fig-61-Part-Usage-for-vehicle_b.svg` under
|
|
109
|
+
`~/proj/third_party/SysML-v2-Release/doc/images/sysml/` — every other kind pair
|
|
110
|
+
(`attribute`, `port`, `item`, `requirement`, `action`, `state`, `calc`,
|
|
111
|
+
`constraint`, `allocation`, `interface`, `view`, `viewpoint`) has its own
|
|
112
|
+
`fig-NN-<Kind>-Definition-and-Usage.svg` in that directory and is the
|
|
113
|
+
acceptance criterion for extending the table.
|
|
114
|
+
|
|
115
|
+
## API
|
|
116
|
+
|
|
117
|
+
```tex
|
|
118
|
+
\sysmldefnode[at={(0,0)}]{veh}{part def}{Vehicle}{%
|
|
119
|
+
\sysmlcomp{attributes}
|
|
120
|
+
brakePedalDepressed: Boolean\\
|
|
121
|
+
\sysmlcomp{actions}
|
|
122
|
+
\kw{perform} providePower\\
|
|
123
|
+
}
|
|
124
|
+
\sysmlusagenode[below=of veh]{vehb}{part}{vehicle\_b}{Vehicle}{%
|
|
125
|
+
\sysmlcomp{attributes}
|
|
126
|
+
cargoMass \kw{default} 0 [kg]\\
|
|
127
|
+
\redefines{:>>Vehicle::cargoMass}\\
|
|
128
|
+
}
|
|
129
|
+
\sysmlport{veh}{east}{fuelIn}{above right}
|
|
130
|
+
\draw[sysml composition] (vehb) -- node[right, font=\scriptsize, pos=.5]{1} (veh);
|
|
131
|
+
\draw[sysml satisfy] (vehb) -- node[right, font=\scriptsize, pos=.5]{«satisfy»} (req);
|
|
132
|
+
\draw[sysml itemflow] (veh-p-east) to[out=0,in=0,looseness=1.15] (vehb-p-east);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Edge styles: `sysml composition` (filled diamond), `sysml reference`,
|
|
136
|
+
`sysml itemflow`, `sysml satisfy`, `sysml gen` (hollow triangle).
|
|
137
|
+
|
|
138
|
+
## Licence
|
|
139
|
+
|
|
140
|
+
`sysml2tikz.sty` is **MPL-2.0 OR LPPL-1.3c** (dual, your choice — LPPL is the branch
|
|
141
|
+
CTAN/TeX Live can carry). The Python under `gen/` and `tools/` is **MPL-2.0**.
|
|
142
|
+
SPDX identifiers are in every file; full texts and the reasoning are in `LICENSES/`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
sysml2tikz/__init__.py,sha256=i8kICbcuWK5RxvgbmILyApa0vR3_EECFk-sCZw-Vfa4,927
|
|
2
|
+
sysml2tikz/cli.py,sha256=BhHVp9Pe9nW3x2YzhHFBhV8mUi-W9QhKpKHFx0Z3hA8,1406
|
|
3
|
+
sysml2tikz/emit.py,sha256=0Hz7iiVq4xBO_JfSL1jBw4DeBiUK4xz2wPny6Ae_VVI,6159
|
|
4
|
+
sysml2tikz/layout.py,sha256=hGwc_pa6amDe60j-gtqZQlE0RXEJU4to2uwC97WxZc0,11642
|
|
5
|
+
sysml2tikz/project.py,sha256=LsjIHAipcrttoNuTBL7uSJ8zlpFcoO8Ug516H4sZbd8,7535
|
|
6
|
+
sysml2tikz/sysml2tikz.sty,sha256=x3N5OIzWJSOyvdxeYtXbUJA7AofQFQzaZ0kH5GM4VVk,5298
|
|
7
|
+
sysml2tikz-0.0.1.dist-info/METADATA,sha256=RmaCb2kQEWzgFLgFhSF7hmhA5ld3TeZziVSNj5Yo264,5570
|
|
8
|
+
sysml2tikz-0.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
sysml2tikz-0.0.1.dist-info/entry_points.txt,sha256=MJxcnvuUjzRFxFXsWJnaCtzLgPBokqdIV9c85xNd0hI,51
|
|
10
|
+
sysml2tikz-0.0.1.dist-info/RECORD,,
|