From 8bd07396532470f3638d97f3880aab8d2d8ab68f Mon Sep 17 00:00:00 2001 From: Samer Ayadi Date: Wed, 19 Aug 2026 15:09:39 +0200 Subject: [PATCH] Write connection blocks and a permanent UID per TRO Each TRO marker gains a UID attribute: a unique, permanent four-digit number in the same space as the TeileId of the CSV export and the ID attribute of SSG_LIB (dxfmakros/Lisp/ssg_id.lsp). The visible ID (TRO03) is untouched - it stays the speaking name that TRO_EDIT and TRO_INSERT work with. The UID is a separate tag on purpose: IDSCHECK treats ID as numeric and would overwrite TRO03 with a number while correcting duplicates. Numbers are allocated above the highest already in use, taken from two sources - the ID attributes of the drawing and the TeileId of every CSV object. The second source is the one that matters here: a mechanical drawing not built with SSG_LIB carries no ID attributes at all (in the Mubea plan only 10 of 669 blocks have any attributes), so without it the run would start at 0001 and take numbers the CSV already uses for components. With it the markers start at 0062, above TeileId 0061. With --flow each edge now also gets a CONNECTION_ARROW block at the midpoint of the arrow, rotated along it, holding FROM_UID/TO_UID plus FROM_TRO/TO_TRO as readable text and KIND. That makes the relationship extractable from a DXF export and stable when a marker is moved or renamed. The shaft and head stay separate geometry: a block with fixed geometry cannot span two arbitrary points, and scaling it in X would distort the arrowhead. Block name prefix CONNECTION_ so the SSG_BLOCKEDIT dispatcher in dxfmakros picks it up via "CONNECTION_*". Verified on the real drawing: 10 markers with UIDs 0062-0071, 29 connections with UIDs 0072-0100, every FROM_UID/TO_UID matching its marker, all 39 UIDs unique. Co-Authored-By: Claude Opus 5 (1M context) --- lib/tro_annotate.py | 297 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 270 insertions(+), 27 deletions(-) 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)