diff --git a/lib/material_flow.py b/lib/material_flow.py index a93465a..7c784bb 100644 --- a/lib/material_flow.py +++ b/lib/material_flow.py @@ -692,26 +692,53 @@ def find_dot() -> str | None: return shutil.which("dot") -def dot_to_svg(dot_file: Path, svg_file: Path) -> None: - """SVG aus der .dot-Datei erzeugen (Graphviz).""" - executable = find_dot() +def find_engine(engine: str) -> str | None: + """Layout-Programm von Graphviz suchen ('dot', 'neato', ...).""" + if engine == "dot": + return find_dot() + return shutil.which(engine) + + +def graph_to_svg( + dot_file: Path, svg_file: Path, engine: str = "dot", no_op: int = 0 +) -> None: + """ + SVG aus der .dot-Datei erzeugen. + + engine Layout-Programm ('dot' fuer die berechnete Anordnung, 'neato' fuer + vorgegebene Koordinaten) + no_op nur fuer neato: 1 oder 2 setzt den No-op-Schalter (-n / -n2), dann + werden die pos-Attribute der Knoten als Position uebernommen und + nur die Kanten berechnet. Alle Knoten brauchen dann ein pos. + """ + executable = find_engine(engine) if not executable: raise RuntimeError( - "Graphviz 'dot' nicht gefunden. Bitte Graphviz installieren " - "(https://graphviz.org/download/) oder GRAPHVIZ_DOT auf die " - "dot-Programmdatei setzen." + f"Graphviz '{engine}' nicht gefunden. Bitte Graphviz installieren " + f"(https://graphviz.org/download/)" + + (" oder GRAPHVIZ_DOT auf die dot-Programmdatei setzen." + if engine == "dot" else ".") ) - result = subprocess.run( - [executable, "-Tsvg", str(dot_file), "-o", str(svg_file)], - capture_output=True, - text=True, - ) + command = [executable] + if no_op: + command.append(f"-n{no_op}") + command += ["-Tsvg", str(dot_file), "-o", str(svg_file)] + + result = subprocess.run(command, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError( - f"'dot' ist mit Code {result.returncode} fehlgeschlagen:\n" + f"'{engine}' ist mit Code {result.returncode} fehlgeschlagen:\n" f"{(result.stderr or result.stdout).strip()}" ) + # neato meldet fehlende Positionen nur als Warnung auf stderr + if result.stderr.strip(): + raise RuntimeError(f"'{engine}' meldet: {result.stderr.strip()}") + + +def dot_to_svg(dot_file: Path, svg_file: Path) -> None: + """SVG mit der berechneten Anordnung von 'dot' erzeugen.""" + graph_to_svg(dot_file, svg_file, engine="dot") # --------------------------------------------------------------------------- diff --git a/lib/tro_flow.py b/lib/tro_flow.py index a00058b..04720dc 100644 --- a/lib/tro_flow.py +++ b/lib/tro_flow.py @@ -13,6 +13,10 @@ Schwesterprogramm zu material_flow.py: Gleiche Schalter (--file, --tosvg, --doc); --tosvg erzeugt hier das Flussdiagramm der TROs, nicht das der Einzelobjekte. +Zusaetzlich --use-cords: jeder TRO traegt die Koordinate seiner Bauteile, und +das SVG wird mit "neato -n" lagerichtig gezeichnet statt von "dot" angeordnet. +Der Graph sieht dann aus wie das Anlagenlayout. + Was ein TRO ist --------------- Ein TRO ("Transfer Route Object") ist die Steuerungslogik **einer @@ -42,8 +46,8 @@ lib/tro_catalog.py. Ausgabe (alles in SKEL_RESULTS) ------------------------------- - _tro_flow.dot immer - _tro_flow.svg mit --tosvg (Graphviz) + _tro_flow.dot immer (mit --use-cords lagerichtige pos-Attribute) + _tro_flow.svg mit --tosvg (dot, mit --use-cords neato -n) _tro_doc.md mit --doc _tro_errors.md nur wenn TRO-Fehler gefunden wurden @@ -73,8 +77,8 @@ from material_flow import ( _dot_label, _merkmal, build_graph, - dot_to_svg, env_dir, + graph_to_svg, read_elements, resolve_input, ) @@ -98,6 +102,14 @@ MIN_STORE_LANES = 2 GENERATED_MARKER = "" +# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in +# Millimeter. 0.25 pt/mm entspricht 1:4 - bei ~11 x 20 m Anlage ergibt das eine +# Zeichnung von etwa 2800 x 5000 pt, in der sich die TRO-Kaesten nicht ueberdecken. +COORD_SCALE = 0.25 + +# Abstand der Legende zum Anlagenrand in mm +LEGEND_MARGIN = 3000 + # --------------------------------------------------------------------------- # Datenmodell @@ -526,17 +538,47 @@ def _tro_label(tro: Tro) -> str: return _dot_label(lines) -def render_dot(analysis: Analysis, source: str) -> str: - """Gerichtetes Flussdiagramm der TROs als Graphviz-Quelle.""" +def missing_positions(analysis: Analysis) -> list[str]: + """TROs ohne Koordinate - mit ihnen ist kein lagerichtiges Layout moeglich.""" + return [t.tro_id for t in analysis.tros if not t.position] + + +def _legend_pos(analysis: Analysis) -> tuple[float, float]: + """Legende links unterhalb der Anlage platzieren (mm).""" + xs = [t.position[0] for t in analysis.tros if t.position] + ys = [t.position[1] for t in analysis.tros if t.position] + if not xs: + return (0.0, 0.0) + return (min(xs) - LEGEND_MARGIN, min(ys) - LEGEND_MARGIN) + + +def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str: + """ + Gerichtetes Flussdiagramm der TROs als Graphviz-Quelle. + + use_coords=False Anordnung berechnet 'dot' (Fluss von links nach rechts) + use_coords=True jeder Knoten wird auf seine Anlagenkoordinate gesetzt und + mit "neato -n" lagerichtig gezeichnet + """ out: list[str] = [] add = out.append add("// Automatisch erzeugt von lib/tro_flow.py - nicht manuell aendern.") add(f"// Quelle: {source}") + if use_coords: + add(f"// Lagerichtig: Knoten auf Anlagenkoordinate, {COORD_SCALE} pt/mm.") + add("// Rendern mit: neato -n -Tsvg .dot -o .svg") add("digraph TROFluss {") - add(" graph [rankdir=LR, splines=spline, nodesep=0.35, ranksep=1.3,") - add(' fontname="Segoe UI", fontsize=11, labelloc="t",') - add(f' label="TRO-Fluss - {_dot_escape(source)}"];') + if use_coords: + # Bei -n bestimmen die pos-Attribute die Lage; rankdir/ranksep entfallen. + add(" graph [splines=true, outputorder=edgesfirst,") + add(' fontname="Segoe UI", fontsize=11, labelloc="t",') + add(f' label="TRO-Fluss lagerichtig ({COORD_SCALE} pt/mm) - ' + f'{_dot_escape(source)}"];') + else: + add(" graph [rankdir=LR, splines=spline, nodesep=0.35, ranksep=1.3,") + add(' fontname="Segoe UI", fontsize=11, labelloc="t",') + add(f' label="TRO-Fluss - {_dot_escape(source)}"];') add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];') add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,' " arrowsize=0.8];") @@ -548,11 +590,13 @@ def render_dot(analysis: Analysis, source: str) -> str: "fillcolor": "#ffffff", "color": "#c00000", "fontcolor": "#c00000" } rendered = ", ".join(f'{k}="{v}"' for k, v in attrs.items()) - # Anlagenkoordinate mitgeben: dot ignoriert pos, "neato -n" zeichnet - # damit den Graphen lagerichtig (Millimeter -> Punkt, 1:100). + # Anlagenkoordinate mitgeben. Ohne --use-cords ignoriert dot das pos, + # die Angabe bleibt aber als Information im DOT stehen. pos = "" if tro.position: - pos = f', pos="{tro.position[0] / 100:.2f},{tro.position[1] / 100:.2f}"' + scale = COORD_SCALE if use_coords else 0.01 + pos = (f', pos="{tro.position[0] * scale:.2f},' + f'{tro.position[1] * scale:.2f}"') add(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];') add("") @@ -570,22 +614,33 @@ def render_dot(analysis: Analysis, source: str) -> str: f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];') add("") - # Legende: eine Zeile je Farbgruppe, plus die Kantenarten - add(" subgraph cluster_legende {") - add(' label="Legende"; style="rounded"; color="#a6a6a6";') - add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";') + # Legende: eine Zeile je Farbgruppe, plus die Kantenarten. + # Bei -n braucht auch sie ein pos, sonst landet sie auf 0,0 mitten in der + # Anlage - daher unterhalb links vom Anlagenrand. rows = "".join( f' {s.group} ' f'{s.label}' for s in STYLES ) - add(' legende [shape=plaintext, style="", fillcolor="none", label=<' - '' - + rows - + '' - '' - "
durchgezogenMaterialfluss
gepunktetgleiche Bahn / Strecke
>];") - add(" }") + legend_label = ('<' + + rows + + '' + '' + '' + '' + "
durchgezogenMaterialfluss
gepunktetgleiche Bahn / Strecke
>") + if use_coords: + lx, ly = _legend_pos(analysis) + add(f' legende [shape=plaintext, style="", fillcolor="none", ' + f'label={legend_label}, pos="{lx * COORD_SCALE:.2f},' + f'{ly * COORD_SCALE:.2f}"];') + else: + add(" subgraph cluster_legende {") + add(' label="Legende"; style="rounded"; color="#a6a6a6";') + add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";') + add(f' legende [shape=plaintext, style="", fillcolor="none", ' + f'label={legend_label}];') + add(" }") add("}") return "\n".join(out) + "\n" @@ -879,6 +934,7 @@ def report( error_file: Path | None, doc_file: Path | None, warnings: list[str], + engine: str = "dot", ) -> None: types_used: dict[str, int] = {} for tro in analysis.tros: @@ -896,6 +952,10 @@ def report( f"nicht verbunden: {len(analysis.isolated)}") print(f"Typdefinition = lib/tro_catalog.py (Stand {CATALOG_AS_OF}), " f"{len(TRO_CATALOG)} Typen") + print(f"Layout = {engine}" + + (" (lagerichtig, neato -n auf Anlagenkoordinaten, " + f"{COORD_SCALE} pt/mm)" if engine == "neato" + else " (berechnete Anordnung; lagerichtig mit --use-cords)")) print(f"DOT = {dot_file}") print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}") print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}") @@ -943,6 +1003,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: " tro_flow.bat --file mubea.csv\n" " tro_flow.bat --file mubea.csv --tosvg\n" " tro_flow.bat --file mubea.csv --tosvg --doc\n" + " tro_flow.bat --file mubea.csv --tosvg --use-cords\n" "\n" "Exit-Codes:\n" " 0 ok\n" @@ -963,6 +1024,15 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help="Aus der erzeugten DOT-Datei zusaetzlich das TRO-Flussdiagramm als " "SVG erzeugen (benoetigt Graphviz 'dot').", ) + parser.add_argument( + "--use-cords", + "--use-coords", + dest="use_cords", + action="store_true", + help="Lagerichtig zeichnen: jeder TRO wird auf seine Anlagenkoordinate " + "gesetzt und das SVG von Graphviz 'neato -n' erzeugt statt von " + f"'dot'. Massstab {COORD_SCALE} pt/mm. Nur mit --tosvg wirksam.", + ) parser.add_argument( "--doc", action="store_true", @@ -1013,15 +1083,29 @@ def main(argv: list[str] | None = None) -> int: doc_target = results / f"{stem}_tro_doc.md" error_target = results / f"{stem}_tro_errors.md" - dot_file.write_text(render_dot(analysis, csv_file.name), encoding="utf-8") + # Ohne Koordinaten ist kein lagerichtiges Layout moeglich + without = missing_positions(analysis) + if args.use_cords and without: + print(f"FEHLER: --use-cords nicht moeglich, {len(without)} TRO(s) ohne " + f"Koordinate: {', '.join(without)}", file=sys.stderr) + print(" Ohne pos an jedem Knoten kann 'neato -n' nicht zeichnen.", + file=sys.stderr) + return 1 + + dot_file.write_text( + render_dot(analysis, csv_file.name, use_coords=args.use_cords), + encoding="utf-8", + ) has_errors = write_error_file(analysis, csv_file, error_target) error_file = error_target if has_errors else None + engine = "neato" if args.use_cords else "dot" svg_error: str | None = None if args.tosvg: try: - dot_to_svg(dot_file, svg_file) + graph_to_svg(dot_file, svg_file, engine=engine, + no_op=1 if args.use_cords else 0) except RuntimeError as exc: svg_error = str(exc) created_svg = svg_file if args.tosvg and svg_error is None else None @@ -1034,7 +1118,8 @@ def main(argv: list[str] | None = None) -> int: ) doc_file = doc_target - report(analysis, csv_file, dot_file, created_svg, error_file, doc_file, warnings) + report(analysis, csv_file, dot_file, created_svg, error_file, doc_file, + warnings, engine=engine) if svg_error: print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)