# -*- coding: utf-8 -*- # Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3" # ableiten und scheitert, wenn keine PythonCore-Installation registriert ist. """ tro_annotate.py - TRO-Informationen in die BricsCAD-Zeichnung einblenden. Nimmt die Originalzeichnung (DXF), den CSV-Export und die daraus ermittelten TROs und schreibt eine **Kopie** der Zeichnung mit zusaetzlicher Beschriftung: * je TRO ein farbiger Marker als Block mit Attributen * TRO-Kennung, TRO-Typ, optional der Siemens-Funktionsbaustein * optional Pfeile fuer den gerichteten Materialfluss zwischen den TROs * optional eine Legende Die Originalzeichnung wird nie veraendert. Alle Ergaenzungen liegen auf eigenen Layern (Vorgabe: Praefix "TRO_") und tragen XDATA der Anwendungskennung SPS_SKEL_TRO. Ein erneuter Lauf entfernt genau diese Objekte und baut sie neu - gelaeuscht wird nach XDATA, nicht nach Layername, damit eigene Objekte auf denselben Layern unangetastet bleiben. Marker als Block mit Attributen ------------------------------- Je TRO-Typ wird eine Blockdefinition angelegt (TRO_SYM_) und an der Anlagenkoordinate eingefuegt. **Jeder Typ hat seine eigene Form** (Kreis, Dreieck, Rechteck, Raute ...; siehe TroSymbol in tro_catalog), sodass sich die Typen auch ohne Farbe unterscheiden. Die Daten stehen als Attribute am Block: ID TRO03 sichtbar, im Dialog bearbeitbar TYPE 1Sep sichtbar, im Dialog bearbeitbar FB_BLOCK FB_ILS_... sichtbar nur mit --fb UID 0062 unsichtbar, dauerhafte interne Nummer ITEMS 1x Separator unsichtbar, reine Daten CONFIDENCE mittel unsichtbar SEPARATORS 0010 unsichtbar Zwei Kennungen, mit Absicht: ID die sprechende Kennung (TRO03). Darf sich aendern - der Dialog TRO_EDIT und TRO_INSERT in dxfmakros arbeiten damit. UID die eindeutige, dauerhafte interne Nummer. Vierstellig wie die TeileId des CSV-Exports und wie das ID-Attribut von SSG_LIB (dxfmakros/Lisp/ssg_id.lsp), damit sie im selben Nummernraum liegt und nicht mit bestehenden Nummern kollidiert; vergeben wird oberhalb der hoechsten schon belegten Nummer aus Zeichnung *und* CSV. Bewusst ein eigenes Tag und nicht ID: in dieser Zeichnung steht in ID schon die sprechende Kennung, und IDSCHECK wuerde sie beim Pruefen auf Dubletten ueberschreiben. Verbindungen ------------ Mit --flow wird je Kante zusaetzlich ein Block CONNECTION_ARROW in die Mitte des Pfeils gesetzt. Er haelt die Beziehung ueber die **UIDs** fest (FROM_UID/TO_UID, dazu FROM_TRO/TO_TRO als Klartext), sodass sie im DXF-Export eindeutig lesbar bleibt, auch wenn ein Marker verschoben oder seine sprechende Kennung geaendert wird. Damit sind die Daten in BricsCAD auswertbar und aenderbar. Zum Bearbeiten gibt es die Dialoge in dxfmakros: TRO_EDIT (dcl/tro_edit.dcl) und CONNECTION_EDIT (dcl/connection_edit.dcl); die Liste der gueltigen Typen dafuer schreibt --emit-lisp aus dem Katalog nach DXFM_LISP. Lagebezug --------- CSV und Zeichnung stehen in verschiedenen Koordinatensystemen. Der Versatz wird von lib/dxf_registration.py ermittelt, nachgewiesen und in SKEL_CFG abgelegt; spaetere Laeufe verwenden den abgelegten Wert. --re-register erzwingt eine neue Ermittlung, --offset setzt ihn von Hand. Ausgabe (in SKEL_RESULTS) ------------------------- _annotated.dxf Kopie der Zeichnung mit Beschriftung _registration.json verwendeter Lagebezug samt Nachweis Aufruf ueber bin/tro_annotate.bat bzw. bin/tro_annotate.sh. Benoetigt ezdxf (siehe requirements.txt). DWG wird nicht unterstuetzt - die Zeichnung muss als DXF vorliegen. """ from __future__ import annotations import argparse import collections import csv as csv_module import fnmatch import json import math import os import sys from pathlib import Path import ezdxf from ezdxf.enums import TextEntityAlignment from dxf_registration import ( ANCHOR_BLOCKS, RegistrationError, Transform, derive_transform, load_transform, save_transform, verify_transform, ) from material_flow import build_graph, env_dir, read_elements, resolve_input from tro_catalog import STYLES, TRO_CATALOG, TroSymbol, get_tro from tro_flow import Analysis, analyse_tros from tro_overrides import load_overrides # --------------------------------------------------------------------------- # Konstanten # --------------------------------------------------------------------------- APPID = "SPS_SKEL_TRO" LAYER_PREFIX = "TRO_" BLOCK_PREFIX = "TRO_SYM_" # Verbindungsblock. Der Name beginnt mit CONNECTION_, damit der Dispatcher # SSG_BLOCKEDIT in dxfmakros ihn ueber "CONNECTION_*" erkennt. CONNECTION_BLOCK = "CONNECTION_ARROW" # Zeichnungsgroessen in mm, wenn nicht anders angegeben DEFAULT_MARKER = 300.0 DEFAULT_TEXT = 220.0 # Restfehler, ab dem der Lagebezug abgelehnt wird DEFAULT_MAX_RESIDUAL = 600.0 def hex_to_rgb(value: str) -> tuple[int, int, int]: """'#e8d4f0' -> (232, 212, 240)""" text = value.lstrip("#") return (int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)) def lisp_dir() -> Path: """ Zielordner fuer die erzeugte LISP-Typliste. Bevorzugt DXFM_LISP - dort liegen die SSG_LIB-Module in dxfmakros, die den Dialog TRO_EDIT bereitstellen. Ohne die Variable landet die Datei in /cad, damit der Lauf auch ohne dxfmakros funktioniert. """ env = os.environ.get("DXFM_LISP") if env: return Path(env) return Path(__file__).resolve().parent.parent / "cad" def emit_lisp(cad_dir: Path) -> Path: """ Typliste fuer den DCL-Dialog schreiben. Der Dialog TRO_EDIT (dxfmakros/Lisp/Tro_Edit.lsp) bietet TYPE als Auswahlliste an. Damit sie nicht von der Typdefinition abweicht, wird sie hier aus TRO_CATALOG erzeugt statt in der LISP-Datei gepflegt. """ cad_dir.mkdir(parents=True, exist_ok=True) path = cad_dir / "tro_types.lsp" types = " ".join(f'"{d.get_name()}"' for d in TRO_CATALOG) path.write_text( ";; Automatisch erzeugt von lib/tro_annotate.py --emit-lisp\n" ";; Quelle: TRO_CATALOG in lib/tro_catalog.py - nicht manuell aendern.\n" f"(setq *TRO-TYPES* (list {types}))\n" "(princ)\n", encoding="utf-8", ) return path # --------------------------------------------------------------------------- # Lagebezug # --------------------------------------------------------------------------- def anchor_points_csv(elements) -> dict[str, list[tuple[float, float]]]: """Punktartige CSV-Objekte je Rolle (Separator, Scanner).""" points: dict[str, list[tuple[float, float]]] = collections.defaultdict(list) for element in elements: if element.kind in ANCHOR_BLOCKS and element.position: points[element.kind].append((element.x, element.y)) return dict(points) def anchor_points_dxf(msp) -> dict[str, list[tuple[float, float]]]: """Einfuegepunkte der Anker-Bloecke je Rolle. ANCHOR_BLOCKS enthaelt Namensmuster, keine festen Namen - die von den Makros erzeugten Zeichnungen haengen an jeden Blocknamen ein `_2D`/`_3D` an (Separator_SP_3D statt S-SP). """ points: dict[str, list[tuple[float, float]]] = collections.defaultdict(list) for ref in msp.query("INSERT"): name = ref.dxf.name.upper() for role, patterns in ANCHOR_BLOCKS.items(): if any(fnmatch.fnmatchcase(name, p.upper()) for p in patterns): points[role].append((ref.dxf.insert.x, ref.dxf.insert.y)) break return dict(points) def resolve_transform( args, elements, msp, cfg_dir: Path, csv_name: str, dxf_name: str, warnings: list[str], ) -> Transform: """Lagebezug bestimmen: Kommandozeile, Konfiguration oder Ermittlung.""" csv_points = anchor_points_csv(elements) dxf_points = anchor_points_dxf(msp) if args.offset: try: dx, dy = (float(v) for v in args.offset.split(",")) except ValueError: raise RegistrationError( f"--offset '{args.offset}' nicht lesbar, erwartet 'dx,dy'" ) from None transform = Transform(dx=dx, dy=dy, source="cli", csv_file=csv_name, dxf_file=dxf_name, anchor="von Hand gesetzt") verify_transform(transform, csv_points, dxf_points) return transform if not args.re_register: stored = load_transform(cfg_dir, csv_name, dxf_name) if stored: # Auch der abgelegte Bezug wird gegen die Zeichnung nachgerechnet verify_transform(stored, csv_points, dxf_points) return stored transform = derive_transform(csv_points, dxf_points, csv_name, dxf_name) saved = save_transform(cfg_dir, transform) warnings.append(f"Lagebezug neu ermittelt und abgelegt: {saved}") return transform # --------------------------------------------------------------------------- # Zeichnung vorbereiten # --------------------------------------------------------------------------- def ensure_appid(doc) -> None: if APPID not in doc.appids: doc.appids.add(APPID) def remove_previous(msp) -> int: """Objekte frueherer Laeufe entfernen - erkannt an ihren XDATA.""" doomed = [] for entity in msp: try: if entity.get_xdata(APPID): doomed.append(entity) except (ezdxf.DXFValueError, ezdxf.DXFTypeError): continue for entity in doomed: msp.delete_entity(entity) return len(doomed) def tag(entity, tro_id: str = "") -> None: """Objekt als von diesem Programm erzeugt kennzeichnen.""" entity.set_xdata(APPID, [(1000, "tro_annotate"), (1000, tro_id or "-")]) def format_internal_id(number: int) -> str: """Vierstellige interne Nummer, wie (ssg-id-format n) in dxfmakros.""" return f"{number:04d}" def next_internal_id(msp, elements=None) -> int: """ Naechste freie interne ID. SSG_LIB vergibt jedem eingefuegten Baustein im Attribut ID eine eindeutige vierstellige Nummer (Lisp/ssg_id.lsp); dieselbe Nummer erscheint im CSV-Export als TeileId. Beides ist **derselbe** ID-Raum. Deshalb wird das Maximum aus zwei Quellen gebildet: * ID-Attribute der Bloecke in der Zeichnung * TeileId aller Objekte des CSV-Exports Die zweite Quelle ist der wichtigere Fall: eine mechanische Zeichnung, die nicht mit SSG_LIB gebaut wurde, hat gar keine ID-Attribute (im Mubea-Plan tragen nur 10 von 669 Bloecken ueberhaupt Attribute). Ohne die TeileIds wuerde die Beschriftung bei 0001 anfangen und damit Nummern belegen, die im CSV schon Bauteile bezeichnen - IDSCHECK saehe spaeter Dubletten. """ highest = 0 for ref in msp.query("INSERT"): try: attribs = ref.attribs except AttributeError: continue for attrib in attribs: if attrib.dxf.tag.upper() != "ID": continue text = (attrib.dxf.text or "").strip() if text.isdigit(): highest = max(highest, int(text)) for element in elements or (): teile_id = (element.teile_id or "").strip() if teile_id.isdigit(): highest = max(highest, int(teile_id)) return highest + 1 def build_layers(doc, prefix: str) -> dict[str, str]: """Layer je Farbgruppe plus Label-, Fluss- und Legendenlayer.""" names: dict[str, str] = {} for style in STYLES: name = f"{prefix}MARKER_{style.group}" names[style.group] = name if name not in doc.layers: # Strichfarbe, nicht Fuellfarbe: im CAD ist das Symbol eine Linie. # Die pastelligen Fuellfarben waeren als Linien kaum zu sehen. doc.layers.add(name, dxfattribs={ "true_color": ezdxf.rgb2int(hex_to_rgb(style.stroke)), }) # openpoint: im Review gemeldete Stellen, die noch KEIN TRO sind. Kraeftiges # Rot, damit sie sich von den abgeleiteten Markern klar abheben. for key, colour in (("label", "#404040"), ("flow", "#2f5597"), ("legend", "#808080"), ("openpoint", "#e00000")): name = f"{prefix}{key.upper()}" names[key] = name if name not in doc.layers: doc.layers.add(name, dxfattribs={ "true_color": ezdxf.rgb2int(hex_to_rgb(colour)), }) return names def _polygon(sides: int, radius: float, start_deg: float) -> list[tuple[float, float]]: """Eckpunkte eines regelmaessigen Vielecks um den Ursprung.""" return [ (radius * math.cos(math.radians(start_deg + i * 360 / sides)), radius * math.sin(math.radians(start_deg + i * 360 / sides))) for i in range(sides) ] def draw_shape(blk, symbol: str, r: float) -> None: """ Symbolgeometrie in eine Blockdefinition zeichnen. Farbe 256 = BYLAYER, damit die Farbe des INSERT-Layers (Farbgruppe des Typs) durchschlaegt. Alle Formen liegen um den Ursprung, damit der Einfuegepunkt des Blocks die Anlagenkoordinate ist. """ attr = {"color": 256} if symbol == TroSymbol.CIRCLE: blk.add_circle((0, 0), r, dxfattribs=attr) elif symbol == TroSymbol.DOUBLE_CIRCLE: blk.add_circle((0, 0), r, dxfattribs=attr) blk.add_circle((0, 0), r * 0.6, dxfattribs=attr) elif symbol == TroSymbol.TRIANGLE: blk.add_lwpolyline(_polygon(3, r, 90), close=True, dxfattribs=attr) elif symbol == TroSymbol.TRIANGLE_DOWN: blk.add_lwpolyline(_polygon(3, r, 270), close=True, dxfattribs=attr) elif symbol == TroSymbol.DOUBLE_TRIANGLE_DOWN: blk.add_lwpolyline(_polygon(3, r, 270), close=True, dxfattribs=attr) blk.add_lwpolyline(_polygon(3, r * 0.55, 270), close=True, dxfattribs=attr) elif symbol == TroSymbol.SQUARE: blk.add_lwpolyline(_polygon(4, r, 45), close=True, dxfattribs=attr) elif symbol == TroSymbol.DIAMOND: blk.add_lwpolyline(_polygon(4, r, 90), close=True, dxfattribs=attr) elif symbol == TroSymbol.PENTAGON: blk.add_lwpolyline(_polygon(5, r, 90), close=True, dxfattribs=attr) elif symbol == TroSymbol.HEXAGON: blk.add_lwpolyline(_polygon(6, r, 0), close=True, dxfattribs=attr) elif symbol == TroSymbol.RECT: w, h = r * 1.45, r * 0.72 blk.add_lwpolyline([(-w, -h), (w, -h), (w, h), (-w, h)], close=True, dxfattribs=attr) elif symbol == TroSymbol.ARROW: blk.add_lwpolyline( [(-r * 0.55, -r), (r, 0), (-r * 0.55, r), (-r * 0.55, r * 0.35), (-r, r * 0.35), (-r, -r * 0.35), (-r * 0.55, -r * 0.35)], close=True, dxfattribs=attr) else: # unbekanntes Symbol: Kreis mit Kreuz, faellt auf blk.add_circle((0, 0), r, dxfattribs=attr) blk.add_line((-r, -r), (r, r), dxfattribs=attr) blk.add_line((-r, r), (r, -r), dxfattribs=attr) def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool) -> str: """ Blockdefinition fuer einen TRO-Typ anlegen: Symbol + Attributdefinitionen. Je Typ eine eigene Form (siehe TroSymbol), damit sich die Typen in der Zeichnung auch ohne Farbe unterscheiden. Attribute (Tags nach der Konvention von dxfmakros/Lisp/ssg_id.lsp): ID unsichtbar - die eindeutige, dauerhafte interne Nummer im ID-Raum von SSG_LIB (vierstellig, wie die TeileId im CSV). IDSCHECK prueft und korrigiert sie zeichnungsweit. TRO_ID sichtbar - der sprechende Name (TRO01), vom Dialog aenderbar TYPE sichtbar - TRO-Typ, vom Dialog aenderbar """ name = f"{BLOCK_PREFIX}{type_name}" if name in doc.blocks: return name blk = doc.blocks.new(name=name) definition = get_tro(type_name) draw_shape(blk, definition.get_symbol() if definition else "", marker) gap = marker * 1.5 rows = [("ID", "TRO??", text * 1.35), ("TYPE", type_name, text)] if show_fb: rows.append(("FB_BLOCK", "FB", text * 0.85)) offset = 0.0 for tag_name, default, height in rows: blk.add_attdef( tag_name, insert=(gap, offset), height=height, dxfattribs={"style": "Standard", "color": 256, "text": default}, ) offset -= height * 1.55 # nicht sichtbare Attribute: reine Daten, nicht im Dialog. # ID zuerst - gleiche Reihenfolge wie *strecke-attr-front* in dxfmakros. for tag_name in ("UID", "ITEMS", "CONFIDENCE", "SEPARATORS"): blk.add_attdef( tag_name, insert=(gap, offset), height=text * 0.7, dxfattribs={"style": "Standard", "color": 256, "text": "", "invisible": 1}, ) offset -= text return name # --------------------------------------------------------------------------- # Beschriftung zeichnen # --------------------------------------------------------------------------- def place_markers( msp, doc, analysis: Analysis, transform: Transform, layers: dict[str, str], marker: float, text: float, show_fb: bool, warnings: list[str], next_id: int = 1, ) -> tuple[dict[str, tuple[float, float]], dict[str, str]]: """ Je TRO einen Blockmarker mit Attributen einfuegen. Rueckgabe: (Position je TRO, interne ID je TRO). Die interne ID wird ab `next_id` fortlaufend vergeben, damit sie mit den bereits in der Zeichnung vorhandenen IDs nicht kollidiert. """ placed: dict[str, tuple[float, float]] = {} internal: dict[str, str] = {} for tro in analysis.tros: if not tro.position: warnings.append(f"{tro.tro_id}: keine Koordinate - nicht beschriftet") continue x, y = transform.to_dxf(tro.position[0], tro.position[1]) placed[tro.tro_id] = (x, y) internal[tro.tro_id] = format_internal_id(next_id) next_id += 1 definition = get_tro(tro.type_name) group = definition.get_group() if definition else "ext" block = build_symbol(doc, tro.type_name, marker, text, show_fb) values = { "ID": tro.tro_id, "UID": internal[tro.tro_id], "TYPE": tro.type_name, "ITEMS": ", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())), "CONFIDENCE": tro.confidence, "SEPARATORS": ", ".join(tro.separators), } if show_fb: values["FB_BLOCK"] = tro.fb_block or "-" ref = msp.add_blockref(block, (x, y), dxfattribs={ "layer": layers.get(group, layers["ext"]), }) ref.add_auto_attribs(values) tag(ref, tro.tro_id) return placed, internal def draw_open_points( msp, analysis: Analysis, transform: Transform, layers: dict[str, str], marker: float, text: float, ) -> int: """Offene Punkte aus cfg/tro_overrides.ini als Fragezeichen-Marker setzen. Das sind im Review gemeldete Stellen, die noch kein TRO sind. Sie werden bewusst NICHT als TRO-Symbol gezeichnet, sondern als durchgestrichener Kreis mit Klartext daneben - damit in der Zeichnung sofort erkennbar ist, dass hier eine Entscheidung aussteht und nichts abgeleitet wurde. """ drawn = 0 radius = marker * 1.35 for point in analysis.overrides.open_points: x, y = transform.to_dxf(point.x, point.y) ring = msp.add_circle((x, y), radius, dxfattribs={"layer": layers["openpoint"]}) tag(ring, f"OFFEN:{point.name}") diag = radius * 0.7071 for (x1, y1), (x2, y2) in (((-diag, -diag), (diag, diag)), ((-diag, diag), (diag, -diag))): line = msp.add_line((x + x1, y + y1), (x + x2, y + y2), dxfattribs={"layer": layers["openpoint"]}) tag(line, f"OFFEN:{point.name}") lines = [f"OFFEN - noch kein TRO: {point.name}"] if point.expect: lines.append(f"erwartet: {point.expect}") if point.belongs_to: lines.append(f"heute Teil von: {point.belongs_to}") lines.append(f"Anlage x/y: {point.describe_position()}") label = msp.add_mtext("\\P".join(lines), dxfattribs={ "layer": layers["openpoint"], "char_height": text, }) label.set_location((x + radius * 1.3, y + radius)) tag(label, f"OFFEN:{point.name}") drawn += 1 return drawn def build_connection_symbol(doc, marker: float, text: float) -> str: """ Blockdefinition fuer eine Verbindung anlegen. Der Block sitzt in der Mitte des Pfeils und traegt die Beziehung als Attribute; Schaft und Spitze sind eigene Geometrie auf dem Fluss-Layer. Grund fuer diese Trennung: ein Block mit fester Geometrie kann nicht zwei beliebige Punkte verbinden - ihn in X zu skalieren wuerde die Pfeilspitze verzerren. Der Mittelpunkt-Marker ist anklickbar (Doppelklick -> Dialog) und bleibt beim Zoomen gleich gross. Attribute: UID unsichtbar - eindeutige interne Nummer, dauerhaft FROM_UID unsichtbar - interne UID des Quell-TRO TO_UID unsichtbar - interne UID des Ziel-TRO FROM_TRO sichtbar - sprechender Name der Quelle (TRO01) TO_TRO sichtbar - sprechender Name des Ziels (TRO09) KIND unsichtbar - flow | colocated """ name = CONNECTION_BLOCK if name in doc.blocks: return name blk = doc.blocks.new(name=name) # Kleiner Doppelpfeil-Winkel als Griff, entlang +X ausgerichtet size = marker * 0.55 blk.add_lwpolyline([(-size, size * 0.7), (size, 0), (-size, -size * 0.7)], dxfattribs={"color": 256}) blk.add_circle((0, 0), size * 0.12, dxfattribs={"color": 256}) offset = size * 1.4 for tag_name in ("FROM_TRO", "TO_TRO"): blk.add_attdef( tag_name, insert=(size * 1.3, offset), height=text * 0.8, dxfattribs={"style": "Standard", "color": 256, "text": ""}, ) offset -= text * 1.2 for tag_name in ("UID", "FROM_UID", "TO_UID", "KIND"): blk.add_attdef( tag_name, insert=(size * 1.3, offset), height=text * 0.7, dxfattribs={"style": "Standard", "color": 256, "text": "", "invisible": 1}, ) offset -= text return name def draw_connections( msp, doc, analysis: Analysis, placed: dict[str, tuple[float, float]], internal: dict[str, str], layers: dict[str, str], marker: float, text: float, next_id: int = 1, ) -> int: """ Verbindungen zwischen den TROs zeichnen. Je Verbindung: Schaft und Pfeilspitze als Geometrie plus ein CONNECTION_ARROW-Block in der Mitte, der Quelle und Ziel ueber die **internen IDs** festhaelt. Damit bleibt die Beziehung im DXF-Export lesbar, auch wenn ein Marker verschoben oder umbenannt wird. """ head = marker * 0.9 block = build_connection_symbol(doc, marker, text) drawn = 0 for src, dst in analysis.edges: if src not in placed or dst not in placed: continue x1, y1 = placed[src] x2, y2 = placed[dst] length = math.dist((x1, y1), (x2, y2)) if length <= 2 * marker * 1.1: continue ux, uy = (x2 - x1) / length, (y2 - y1) / length # An beiden Enden um den Marker kuerzen, damit der Pfeil ihn nicht deckt sx, sy = x1 + ux * marker * 1.1, y1 + uy * marker * 1.1 ex, ey = x2 - ux * marker * 1.1, y2 - uy * marker * 1.1 shaft = msp.add_lwpolyline([(sx, sy), (ex, ey)], dxfattribs={"layer": layers["flow"]}) tag(shaft, f"{src}->{dst}") # Pfeilspitze als gefuellte Flaeche bx, by = ex - ux * head, ey - uy * head px, py = -uy * head * 0.38, ux * head * 0.38 point = msp.add_solid( [(ex, ey), (bx + px, by + py), (bx - px, by - py)], dxfattribs={"layer": layers["flow"]}, ) tag(point, f"{src}->{dst}") # Traeger der Beziehung: Block in der Pfeilmitte, entlang des Pfeils ref = msp.add_blockref( block, ((sx + ex) / 2, (sy + ey) / 2), dxfattribs={"layer": layers["flow"], "rotation": math.degrees(math.atan2(uy, ux))}, ) ref.add_auto_attribs({ "UID": format_internal_id(next_id), "FROM_UID": internal.get(src, ""), "TO_UID": internal.get(dst, ""), "FROM_TRO": src, "TO_TRO": dst, "KIND": "flow", }) tag(ref, f"{src}->{dst}") next_id += 1 drawn += 1 return drawn def draw_legend( msp, analysis: Analysis, placed: dict[str, tuple[float, float]], layers: dict[str, str], marker: float, text: float, source: str, ) -> None: """Legende links unterhalb der beschrifteten Objekte.""" if not placed: return x0 = min(p[0] for p in placed.values()) - marker * 12 y0 = min(p[1] for p in placed.values()) - marker * 6 row = text * 2.2 title = msp.add_text( f"TRO-Beschriftung - {source}", height=text * 1.5, dxfattribs={"layer": layers["legend"], "style": "Standard"}, ) title.set_placement((x0, y0 + row)) tag(title) used = {t.type_name for t in analysis.tros} groups = [] for style in STYLES: types = sorted(n for n in used if (get_tro(n).get_group() if get_tro(n) else "ext") == style.group) if types: groups.append((style, types)) for index, (style, types) in enumerate(groups): y = y0 - index * row box = msp.add_solid( [(x0, y), (x0 + text, y), (x0 + text, y - text), (x0, y - text)], dxfattribs={"layer": layers[style.group]}, ) tag(box) label = msp.add_text( f"{style.label}: {', '.join(types)}", height=text, dxfattribs={"layer": layers["legend"], "style": "Standard"}, ) label.set_placement((x0 + text * 1.8, y - text)) tag(label) # --------------------------------------------------------------------------- # Bericht # --------------------------------------------------------------------------- def report( analysis: Analysis, transform: Transform, csv_file: Path, dxf_file: Path, out_file: Path | None, reg_file: Path | None, counts: dict, warnings: list[str], ) -> None: print("") print("================================================================") print("TRO-BESCHRIFTUNG") print("================================================================") print(f"CSV = {csv_file}") print(f"Zeichnung = {dxf_file}") print(f"Lagebezug = {transform.describe()}") print(f" Herkunft = {transform.source}" + (f", ermittelt {transform.created}" if transform.created else "")) print(f" Nachweis = {transform.anchor}") print(f" Restfehler = max {transform.residual_max:.1f} mm, " f"mittel {transform.residual_mean:.1f} mm " f"({transform.matched}/{transform.candidates} Punkte)") print(f"TROs = {len(analysis.tros)}, beschriftet: {counts['marker']}") print(f"Verbindungen = {counts['flow']}" + (" (Block CONNECTION_ARROW mit FROM_UID/TO_UID)" if counts["flow"] else "")) if counts.get("first_id"): print(f"Interne IDs ab = {format_internal_id(counts['first_id'])} " f"(ID-Raum von SSG_LIB, siehe IDSCHECK)") print(f"Offene Punkte = {counts['openpoints']} (Layer TRO_OPENPOINT, " f"noch kein TRO)") if analysis.overrides.applied: print(f"Typkorrekturen = {len(analysis.overrides.applied)} aus " f"{analysis.overrides.source}") for entry in analysis.overrides.applied: print(f" * {entry}") print(f"Entfernte Objekte= {counts['removed']} (aus frueherem Lauf)") print(f"Layer = {counts['layers']}, Bloecke: {counts['blocks']}") print(f"DXF = {out_file if out_file else '- (--check)'}") print(f"Lagebezug-Datei = {reg_file if reg_file else '-'}") print("================================================================") if warnings: print("") print(f"Hinweise ({len(warnings)}):") for warning in warnings: print(f" ! {warning}") print("") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="tro_annotate", description="Blendet die ermittelten TROs in eine Kopie der " "BricsCAD-Zeichnung ein (DXF).", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Beispiele:\n" " tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --check\n" " tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --flow --fb\n" "\n" "Exit-Codes:\n" " 0 ok\n" " 1 Eingabe- oder Aufruffehler\n" " 4 Lagebezug nicht nachweisbar\n", ) parser.add_argument("--file", default="export.csv", metavar="NAME", help="CSV-Export in %%SKEL_DATA%% oder ein Pfad. " "Standard: %(default)s") parser.add_argument("--dxf", metavar="NAME", help="Originalzeichnung als DXF (Pfad oder Name in " "%%SKEL_DATA%%). Ausser bei --emit-lisp erforderlich.") parser.add_argument("--out", metavar="NAME", help="Name der Ausgabedatei in %%SKEL_RESULTS%%. " "Standard: _annotated.dxf") parser.add_argument("--flow", action="store_true", help="Pfeile fuer den Materialfluss zwischen den TROs " "zeichnen.") parser.add_argument("--fb", action="store_true", help="Den Siemens-Funktionsbaustein als sichtbares " "Attribut am Marker anzeigen.") parser.add_argument("--legend", action="store_true", help="Legende der Farbgruppen einzeichnen.") parser.add_argument("--marker-size", type=float, default=DEFAULT_MARKER, metavar="MM", help="Markerradius in mm. Standard: %(default)s") parser.add_argument("--text-height", type=float, default=DEFAULT_TEXT, metavar="MM", help="Texthoehe in mm. Standard: %(default)s") parser.add_argument("--layer-prefix", default=LAYER_PREFIX, metavar="P", help="Praefix der erzeugten Layer. Standard: %(default)s") parser.add_argument("--offset", metavar="DX,DY", help="Lagebezug von Hand setzen statt ermitteln.") parser.add_argument("--re-register", action="store_true", help="Lagebezug neu ermitteln, auch wenn in %%SKEL_CFG%% " "einer abgelegt ist.") parser.add_argument("--max-residual", type=float, default=DEFAULT_MAX_RESIDUAL, metavar="MM", help="Groesster zugelassener Restfehler des Lagebezugs. " "Standard: %(default)s") parser.add_argument("--check", action="store_true", help="Nur pruefen und berichten, keine Datei schreiben.") parser.add_argument("--emit-lisp", action="store_true", help="cad/tro_types.lsp aus dem Typkatalog neu schreiben " "(Auswahlliste des Dialogs TROEDIT) und beenden.") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) warnings: list[str] = [] if args.emit_lisp: path = emit_lisp(lisp_dir()) print(f"Typliste geschrieben: {path} ({len(TRO_CATALOG)} Typen)") if not os.environ.get("DXFM_LISP"): print("HINWEIS: DXFM_LISP nicht gesetzt - die Datei gehoert neben die " "SSG_LIB-Module (dxfmakros/Lisp), damit TRO_EDIT sie findet.") return 0 if not args.dxf: print("FEHLER: --dxf fehlt (nur --emit-lisp geht ohne Zeichnung).", file=sys.stderr) return 1 csv_file = resolve_input(args.file) if not csv_file.is_file(): print(f"FEHLER: CSV nicht gefunden: {csv_file}", file=sys.stderr) return 1 dxf_file = Path(args.dxf) if not dxf_file.is_absolute() and dxf_file.parent == Path("."): dxf_file = env_dir("SKEL_DATA", "data") / args.dxf if not dxf_file.is_file(): print(f"FEHLER: Zeichnung nicht gefunden: {dxf_file}", file=sys.stderr) return 1 if dxf_file.suffix.lower() == ".dwg": print("FEHLER: DWG wird nicht unterstuetzt - bitte als DXF exportieren.", file=sys.stderr) return 1 results = env_dir("SKEL_RESULTS", "results") cfg_dir = env_dir("SKEL_CFG", "cfg") results.mkdir(parents=True, exist_ok=True) # TROs aus dem CSV read_warnings: list[str] = [] try: elements = read_elements(csv_file, read_warnings) except (OSError, ValueError, csv_module.Error) as exc: print(f"FEHLER: {csv_file} nicht lesbar: {exc}", file=sys.stderr) return 1 graph = build_graph(elements, csv_file.name, read_warnings) overrides = load_overrides(csv_file.name, cfg_dir) analysis = analyse_tros(graph, elements, overrides) if not analysis.tros: print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr) return 1 # Zeichnung try: doc = ezdxf.readfile(str(dxf_file)) except (OSError, ezdxf.DXFError) as exc: print(f"FEHLER: {dxf_file} nicht lesbar: {exc}", file=sys.stderr) return 1 msp = doc.modelspace() # Lagebezug try: transform = resolve_transform(args, elements, msp, cfg_dir, csv_file.name, dxf_file.name, warnings) except RegistrationError as exc: print(f"FEHLER: Lagebezug nicht bestimmbar: {exc}", file=sys.stderr) return 4 if transform.residual_max > args.max_residual: print(f"FEHLER: Restfehler des Lagebezugs {transform.residual_max:.1f} mm " f"ueber der Grenze von {args.max_residual:.1f} mm.", file=sys.stderr) print(f" {transform.describe()}", file=sys.stderr) print(" Mit --offset von Hand setzen oder --max-residual anheben.", file=sys.stderr) return 4 counts = {"marker": 0, "flow": 0, "removed": 0, "layers": 0, "blocks": 0, "openpoints": 0, "first_id": 0} if args.check: counts["marker"] = sum(1 for t in analysis.tros if t.position) report(analysis, transform, csv_file, dxf_file, None, None, counts, warnings) return 0 ensure_appid(doc) counts["removed"] = remove_previous(msp) layers = build_layers(doc, args.layer_prefix) counts["layers"] = len({v for v in layers.values()}) # Interne IDs erst NACH remove_previous suchen: die Marker des letzten Laufs # sind dann weg und ihre Nummern wieder frei. next_id = next_internal_id(msp, elements) placed, internal = place_markers( msp, doc, analysis, transform, layers, args.marker_size, args.text_height, args.fb, warnings, next_id=next_id, ) counts["marker"] = len(placed) counts["blocks"] = sum(1 for b in doc.blocks if b.name.startswith(BLOCK_PREFIX)) counts["first_id"] = next_id counts["openpoints"] = draw_open_points(msp, analysis, transform, layers, args.marker_size, args.text_height) if args.flow: counts["flow"] = draw_connections( msp, doc, analysis, placed, internal, layers, args.marker_size, args.text_height, next_id=next_id + len(placed), ) if args.legend: draw_legend(msp, analysis, placed, layers, args.marker_size, args.text_height, csv_file.name) out_file = results / (args.out or f"{csv_file.stem}_annotated.dxf") doc.saveas(str(out_file)) reg_file = results / f"{csv_file.stem}_registration.json" reg_file.write_text( json.dumps(transform.model_dump(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) report(analysis, transform, csv_file, dxf_file, out_file, reg_file, counts, warnings) return 0 if __name__ == "__main__": sys.exit(main())