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 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
+ ![definition and usage](docs/figures/fig-definition-and-usage.png)
78
+
79
+ Same figure generated from a JSON notation IR (`gen/model.json` → `gen/emit_tex.py`) —
80
+ nothing typed by hand:
81
+
82
+ ![generated from IR](docs/figures/fig-generated-from-ir.png)
83
+
84
+ Laid out automatically: TeX-measured box sizes → diagramboxes' orthogonal router →
85
+ absolute coordinates and polyline edges (`gen/laid_out.tex`):
86
+
87
+ ![routed layout](docs/figures/fig-routed-layout.png)
88
+
89
+ End to end from SysML v2 text — `examples/vehicle.sysml` parsed by sysmlpy, projected,
90
+ routed and rendered:
91
+
92
+ ![from sysml model](docs/figures/fig-from-sysml-model.png)
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sysml2tikz = sysml2tikz.cli:main