diff --git a/lib/tro_annotate.py b/lib/tro_annotate.py index 32b6e65..fe6191a 100644 --- a/lib/tro_annotate.py +++ b/lib/tro_annotate.py @@ -28,14 +28,35 @@ 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 -Damit sind sie in BricsCAD auswertbar und aenderbar - und ein korrigiertes ID -kann spaeter zurueck in die Generierung laufen. Zum Bearbeiten gibt es den -Dialog cad/tro_edit.dcl mit dem Befehl TROEDIT (cad/tro_edit.lsp); die Liste der -gueltigen Typen dafuer schreibt --emit-lisp aus dem Katalog. +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 --------- @@ -59,6 +80,7 @@ from __future__ import annotations import argparse import collections import csv as csv_module +import fnmatch import json import math import os @@ -80,6 +102,7 @@ from dxf_registration import ( 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 @@ -88,6 +111,9 @@ from tro_flow import Analysis, analyse_tros 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 @@ -152,13 +178,19 @@ def anchor_points_csv(elements) -> dict[str, list[tuple[float, float]]]: def anchor_points_dxf(msp) -> dict[str, list[tuple[float, float]]]: - """Einfuegepunkte der Anker-Bloecke je Rolle.""" - wanted = {name: role for role, names in ANCHOR_BLOCKS.items() for name in names} + """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"): - role = wanted.get(ref.dxf.name) - if role: - points[role].append((ref.dxf.insert.x, ref.dxf.insert.y)) + 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) @@ -224,6 +256,50 @@ def tag(entity, tro_id: str = "") -> None: 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] = {} @@ -236,8 +312,10 @@ def build_layers(doc, prefix: str) -> dict[str, str]: 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")): + ("legend", "#808080"), ("openpoint", "#e00000")): name = f"{prefix}{key.upper()}" names[key] = name if name not in doc.layers: @@ -274,6 +352,9 @@ def draw_shape(blk, symbol: str, r: float) -> None: 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: @@ -303,8 +384,14 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool) 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. Die Attribute ID und TYPE sind - sichtbar und werden vom DCL-Dialog TROEDIT bearbeitet. + 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: @@ -329,8 +416,9 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool) ) offset -= height * 1.55 - # nicht sichtbare Attribute: reine Daten, nicht im Dialog - for tag_name in ("ITEMS", "CONFIDENCE", "SEPARATORS"): + # 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), @@ -350,15 +438,25 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool) def place_markers( msp, doc, analysis: Analysis, transform: Transform, layers: dict[str, str], marker: float, text: float, show_fb: bool, warnings: list[str], -) -> dict[str, tuple[float, float]]: - """Je TRO einen Blockmarker mit Attributen einfuegen.""" + 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" @@ -366,6 +464,7 @@ def place_markers( 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, @@ -379,15 +478,117 @@ def place_markers( }) ref.add_auto_attribs(values) tag(ref, tro.tro_id) - return placed + return placed, internal -def draw_flow( - msp, analysis: Analysis, placed: dict[str, tuple[float, float]], - layers: dict[str, str], marker: float, +def draw_open_points( + msp, analysis: Analysis, transform: Transform, layers: dict[str, str], + marker: float, text: float, ) -> int: - """Pfeile fuer den gerichteten Materialfluss zwischen den TROs.""" + """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: @@ -412,6 +613,23 @@ def draw_flow( 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 @@ -482,7 +700,18 @@ def report( f"mittel {transform.residual_mean:.1f} mm " f"({transform.matched}/{transform.candidates} Punkte)") print(f"TROs = {len(analysis.tros)}, beschriftet: {counts['marker']}") - print(f"Flusspfeile = {counts['flow']}") + 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)'}") @@ -601,7 +830,8 @@ def main(argv: list[str] | None = None) -> int: print(f"FEHLER: {csv_file} nicht lesbar: {exc}", file=sys.stderr) return 1 graph = build_graph(elements, csv_file.name, read_warnings) - analysis = analyse_tros(graph, elements) + 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 @@ -629,7 +859,8 @@ def main(argv: list[str] | None = None) -> int: file=sys.stderr) return 4 - counts = {"marker": 0, "flow": 0, "removed": 0, "layers": 0, "blocks": 0} + 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) @@ -641,13 +872,25 @@ def main(argv: list[str] | None = None) -> int: layers = build_layers(doc, args.layer_prefix) counts["layers"] = len({v for v in layers.values()}) - placed = place_markers(msp, doc, analysis, transform, layers, - args.marker_size, args.text_height, args.fb, warnings) + # 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_flow(msp, analysis, placed, layers, args.marker_size) + 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)