From 7b98c8b8d46fea0219344d573e46e9b6b5dd8116 Mon Sep 17 00:00:00 2001 From: Samer Ayadi Date: Fri, 2 Oct 2026 13:30:47 +0200 Subject: [PATCH] TRO-Beschriftung wie das TRO-Fluss-SVG; Stationspfeile zeigen von der Kette weg tro_flow: - _station_positionen: Richtung des Zu-/Ablaufpfeils mit groesstem Winkelabstand zu allen Kanten des Anker-TROs und zu bereits gesetzten Stationspfeilen (15-Grad-Raster), echte Stationsrichtung nur bei Gleichstand - vorher lag z. B. Mubea TRO02 -> SYS2 fast auf TRO02 -> TRO13 tro_annotate: - Symbole mit typ-eigener Fuellfarbe (Solid-Hatch) unter der Gruppenkontur - Kantenfarben/-layer wie im SVG: Uebergabe blau, Weiche orange, Materialeingang gruen, Materialausgang rot (TRO_WEICHE/_EINGANG/_AUSGANG) - Stationen als farbige Beschriftung am Pfeilende, Lage ueber tro_flow._station_positionen (ohne CONNECTION_ARROW-Block) - Legende wie im SVG: je vorkommendem Typ Fuellung/Rand/Form, dann Kantenfarben - CONNECTION_ARROW: FROM_TRO/TO_TRO unsichtbar (Daten bleiben), KIND auch weiche cfg/dxf_registration.json: mubea.csv|mubea.dxf und HundM05.csv|HundM05.dxf mit Versatz 0 (CSV aus derselben dxfmakros-Zeichnung; Auto-Ermittlung sieht die verschachtelten Separatoren nicht und war mehrdeutig); mubea.csv|500573_60_1.dxf neu ermittelt (36/46 Punkte). Co-Authored-By: Claude Opus 5.5 --- cfg/dxf_registration.json | 42 +++++++-- lib/tro_annotate.py | 175 +++++++++++++++++++++++++++++++------- lib/tro_flow.py | 48 ++++++++--- 3 files changed, 213 insertions(+), 52 deletions(-) diff --git a/cfg/dxf_registration.json b/cfg/dxf_registration.json index df01f3e..7acd68c 100644 --- a/cfg/dxf_registration.json +++ b/cfg/dxf_registration.json @@ -1,17 +1,17 @@ { "mubea.csv|500573_60_1.dxf": { - "dx": 50905.678327433096, + "dx": 50833.7283274331, "dy": 8414.490373798908, "rotation": 0.0, "source": "auto", - "anchor": "20 exakte Treffer (<= 50 mm) ueber die Rollen Scanner:4, Separator:31; 20 Stimmen im 100-mm-Raster, 6 Kandidaten geprueft", - "matched": 34, - "candidates": 35, - "residual_max": 452.04557170439375, - "residual_mean": 102.6006793267277, + "anchor": "20 exakte Treffer (<= 50 mm) ueber die Rollen Scanner:8, Separator:38; 22 Stimmen im 100-mm-Raster, 6 Kandidaten geprueft", + "matched": 36, + "candidates": 46, + "residual_max": 487.77757110283585, + "residual_mean": 93.12692369578822, "csv_file": "mubea.csv", "dxf_file": "500573_60_1.dxf", - "created": "2026-07-30 15:32" + "created": "2026-10-02 12:45" }, "export.csv|Mubea.dxf": { "dx": 59.950000000000045, @@ -26,5 +26,33 @@ "csv_file": "export.csv", "dxf_file": "Mubea.dxf", "created": "2026-08-12 10:09" + }, + "HundM05.csv|HundM05.dxf": { + "dx": 0.0, + "dy": 0.0, + "rotation": 0.0, + "source": "cli", + "anchor": "von Hand: CSV aus derselben dxfmakros-Zeichnung exportiert, Koordinaten identisch (Auto-Ermittlung sieht die in VF_/KREISEL_-Bloecken verschachtelten Separatoren nicht und war mehrdeutig)", + "matched": 14, + "candidates": 26, + "residual_max": 360.0, + "residual_mean": 164.97465794329284, + "csv_file": "HundM05.csv", + "dxf_file": "HundM05.dxf", + "created": "2026-10-02 13:40" + }, + "mubea.csv|mubea.dxf": { + "dx": 0.0, + "dy": 0.0, + "rotation": 0.0, + "source": "cli", + "anchor": "von Hand: CSV aus derselben dxfmakros-Zeichnung exportiert, Koordinaten identisch (Auto-Ermittlung sieht die in VF_/KREISEL_-Bloecken verschachtelten Separatoren nicht und war mehrdeutig)", + "matched": 20, + "candidates": 46, + "residual_max": 360.0, + "residual_mean": 256.77327549812964, + "csv_file": "mubea.csv", + "dxf_file": "mubea.dxf", + "created": "2026-10-02 13:40" } } diff --git a/lib/tro_annotate.py b/lib/tro_annotate.py index 5e335aa..dde471b 100644 --- a/lib/tro_annotate.py +++ b/lib/tro_annotate.py @@ -101,7 +101,14 @@ 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_flow import ( + BOUNDARY_ENTRY, + BOUNDARY_EXIT, + STATION_FARBE, + Analysis, + _station_positionen, + analyse_tros, +) from tro_overrides import Overrides, load_overrides # --------------------------------------------------------------------------- @@ -314,7 +321,12 @@ def build_layers(doc, prefix: str) -> dict[str, str]: }) # openpoint: im Review gemeldete Stellen, die noch KEIN TRO sind. Kraeftiges # Rot, damit sie sich von den abgeleiteten Markern klar abheben. + # Kantenfarben wie im TRO-Fluss-SVG (tro_flow.render_dot): Uebergabe blau, + # Weiche orange, Materialeingang gruen, Materialausgang rot. for key, colour in (("label", "#404040"), ("flow", "#2f5597"), + ("weiche", "#cc8800"), + ("eingang", STATION_FARBE[BOUNDARY_ENTRY]), + ("ausgang", STATION_FARBE[BOUNDARY_EXIT]), ("legend", "#808080"), ("openpoint", "#e00000")): name = f"{prefix}{key.upper()}" names[key] = name @@ -334,6 +346,40 @@ def _polygon(sides: int, radius: float, start_deg: float) -> list[tuple[float, f ] +def shape_outline(symbol: str, r: float) -> list[tuple[float, float]]: + """Aeussere Kontur eines Symbols als Polygon - Rand der Fuellflaeche + (Kreise als 48-Eck angenaehert).""" + if symbol in (TroSymbol.CIRCLE, TroSymbol.DOUBLE_CIRCLE): + return _polygon(48, r, 0) + if symbol == TroSymbol.TRIANGLE: + return _polygon(3, r, 90) + if symbol in (TroSymbol.TRIANGLE_DOWN, TroSymbol.DOUBLE_TRIANGLE_DOWN): + return _polygon(3, r, 270) + if symbol == TroSymbol.SQUARE: + return _polygon(4, r, 45) + if symbol == TroSymbol.DIAMOND: + return _polygon(4, r, 90) + if symbol == TroSymbol.PENTAGON: + return _polygon(5, r, 90) + if symbol == TroSymbol.HEXAGON: + return _polygon(6, r, 0) + if symbol == TroSymbol.RECT: + w, h = r * 1.45, r * 0.72 + return [(-w, -h), (w, -h), (w, h), (-w, h)] + if symbol == TroSymbol.ARROW: + return [(-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)] + return _polygon(48, r, 0) + + +def draw_fill(blk, symbol: str, r: float, fill_hex: str) -> None: + """Typ-eigene Fuellfarbe (wie im TRO-Fluss-SVG) als Solid-Hatch unter der + Kontur. Feste Farbe statt BYLAYER: der Layer traegt die Randfarbe der + Gruppe, die Fuellung unterscheidet den einzelnen Typ.""" + hatch = blk.add_hatch(dxfattribs={"true_color": ezdxf.rgb2int(hex_to_rgb(fill_hex))}) + hatch.paths.add_polyline_path(shape_outline(symbol, r), is_closed=True) + + def draw_shape(blk, symbol: str, r: float) -> None: """ Symbolgeometrie in eine Blockdefinition zeichnen. @@ -399,7 +445,10 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool) blk = doc.blocks.new(name=name) definition = get_tro(type_name) - draw_shape(blk, definition.get_symbol() if definition else "", marker) + symbol = definition.get_symbol() if definition else "" + if definition: + draw_fill(blk, symbol, marker, definition.get_fill()) + draw_shape(blk, symbol, marker) gap = marker * 1.5 rows = [("ID", "TRO??", text * 1.35), ("TYPE", type_name, text)] @@ -522,6 +571,27 @@ def draw_open_points( return drawn +def place_stations( + msp, analysis: Analysis, placed: dict[str, tuple[float, float]], + layers: dict[str, str], text: float, +) -> dict[str, tuple[float, float]]: + """Be-/Entladestationen wie im TRO-Fluss-SVG: kein Symbol, nur der Name + in Gruen (Eingang) bzw. Rot (Ausgang) am Ende des Zu-/Ablaufpfeils. + Lage wie dort (tro_flow._station_positionen): feste Pfeillaenge ab dem + angehaengten TRO, Richtung von dessen Kanten weg.""" + positionen = _station_positionen(analysis, placed) + for station in analysis.boundaries: + if station.tro_id not in positionen: + continue + x, y = positionen[station.tro_id] + layer = layers["eingang" if station.type_name == BOUNDARY_ENTRY else "ausgang"] + label = msp.add_text(station.label, height=text, + dxfattribs={"layer": layer, "style": "Standard"}) + label.set_placement((x, y), align=TextEntityAlignment.MIDDLE_CENTER) + tag(label, station.tro_id) + return positionen + + def build_connection_symbol(doc, marker: float, text: float) -> str: """ Blockdefinition fuer eine Verbindung anlegen. @@ -537,9 +607,9 @@ def build_connection_symbol(doc, marker: float, text: float) -> str: 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 + FROM_TRO unsichtbar - sprechender Name der Quelle (TRO01) + TO_TRO unsichtbar - sprechender Name des Ziels (TRO09) + KIND unsichtbar - flow | weiche | colocated """ name = CONNECTION_BLOCK if name in doc.blocks: @@ -552,13 +622,17 @@ def build_connection_symbol(doc, marker: float, text: float) -> str: dxfattribs={"color": 256}) blk.add_circle((0, 0), size * 0.12, dxfattribs={"color": 256}) + # FROM_TRO/TO_TRO unsichtbar: an jeder Kante sichtbar machten sie die + # Zeichnung unruhig, im TRO-Fluss-SVG stehen sie auch nicht (Review + # 2026-10-02). Die Daten bleiben am Block, CONNECTION_EDIT zeigt sie. 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": ""}, + dxfattribs={"style": "Standard", "color": 256, "text": "", + "invisible": 1}, ) offset -= text * 1.2 @@ -589,30 +663,47 @@ def draw_connections( """ head = marker * 0.9 block = build_connection_symbol(doc, marker, text) + stationen = {b.tro_id for b in analysis.boundaries} drawn = 0 for src, dst in analysis.edges: if src not in placed or dst not in placed: continue + # Farbe/Layer wie im TRO-Fluss-SVG + if src in stationen: + layer, kind = layers["eingang"], "eingang" + elif dst in stationen: + layer, kind = layers["ausgang"], "ausgang" + elif analysis.edge_kinds.get((src, dst)) == "weiche": + layer, kind = layers["weiche"], "weiche" + else: + layer, kind = layers["flow"], "flow" 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 + # An beiden Enden um den Marker kuerzen, damit der Pfeil ihn nicht + # deckt; das Stationsende ist nur eine Beschriftung, dort weniger. + kurz_a = marker * (0.3 if src in stationen else 1.1) + kurz_b = marker * (0.3 if dst in stationen else 1.1) + sx, sy = x1 + ux * kurz_a, y1 + uy * kurz_a + ex, ey = x2 - ux * kurz_b, y2 - uy * kurz_b shaft = msp.add_lwpolyline([(sx, sy), (ex, ey)], - dxfattribs={"layer": layers["flow"]}) + dxfattribs={"layer": layer}) 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"]}, + dxfattribs={"layer": layer}, ) tag(point, f"{src}->{dst}") + if kind in ("eingang", "ausgang"): + # Stationspfeile ohne Verbindungsblock: keine TRO-TRO-Beziehung + drawn += 1 + continue # Traeger der Beziehung: Block in der Pfeilmitte, entlang des Pfeils ref = msp.add_blockref( @@ -626,7 +717,7 @@ def draw_connections( "TO_UID": internal.get(dst, ""), "FROM_TRO": src, "TO_TRO": dst, - "KIND": "flow", + "KIND": kind, }) tag(ref, f"{src}->{dst}") next_id += 1 @@ -653,28 +744,48 @@ def draw_legend( title.set_placement((x0, y0 + row)) tag(title) + # Wie die Legende im TRO-Fluss-SVG: je vorkommendem Typ Fuellfarbe, Rand + # der Gruppe und Form, danach die Kantenfarben. 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)) + zeilen: list[tuple[str, str | None, str | None]] = [] + for definition in TRO_CATALOG: + if definition.get_name() in used: + zeilen.append((f"{definition.get_name()} - {definition.describe_symbol()}", + definition.get_fill(), + layers.get(definition.get_group(), layers["ext"]))) + pfeile = [("eingang", "Materialeingang (Beladung / Eingang)"), + ("ausgang", "Materialausgang (Entladung / Ausgang)"), + ("flow", "Uebergabe / Ein-Ausschleusung"), + ("weiche", "Weiche (Kreisel-Uebergang)")] - 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]}, - ) + y = y0 + for beschriftung, fill, rand_layer in zeilen: + quadrat = [(x0, y), (x0 + text, y), (x0 + text, y - text), (x0, y - text)] + box = msp.add_hatch(dxfattribs={"layer": layers["legend"], + "true_color": ezdxf.rgb2int(hex_to_rgb(fill))}) + box.paths.add_polyline_path(quadrat, is_closed=True) tag(box) - label = msp.add_text( - f"{style.label}: {', '.join(types)}", - height=text, - dxfattribs={"layer": layers["legend"], "style": "Standard"}, - ) + rand = msp.add_lwpolyline(quadrat, close=True, dxfattribs={"layer": rand_layer}) + tag(rand) + label = msp.add_text(beschriftung, height=text, + dxfattribs={"layer": layers["legend"], "style": "Standard"}) label.set_placement((x0 + text * 1.8, y - text)) tag(label) + y -= row + for key, beschriftung in pfeile: + mitte = y - text / 2 + linie = msp.add_lwpolyline([(x0 - text * 0.5, mitte), (x0 + text * 0.9, mitte)], + dxfattribs={"layer": layers[key]}) + tag(linie) + spitze = msp.add_solid([(x0 + text * 1.4, mitte), (x0 + text * 0.9, mitte + text * 0.3), + (x0 + text * 0.9, mitte - text * 0.3)], + dxfattribs={"layer": layers[key]}) + tag(spitze) + label = msp.add_text(beschriftung, height=text, + dxfattribs={"layer": layers[key], "style": "Standard"}) + label.set_placement((x0 + text * 1.8, y - text)) + tag(label) + y -= row # --------------------------------------------------------------------------- @@ -896,9 +1007,11 @@ def main(argv: list[str] | None = None) -> int: args.marker_size, args.text_height) if args.flow: + # Stationen nur mit --flow: sie bestehen aus ihrem Zu-/Ablaufpfeil + stationen = place_stations(msp, analysis, placed, layers, args.text_height) counts["flow"] = draw_connections( - msp, doc, analysis, placed, internal, layers, args.marker_size, - args.text_height, next_id=next_id + len(placed), + msp, doc, analysis, {**placed, **stationen}, 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, diff --git a/lib/tro_flow.py b/lib/tro_flow.py index 221fc84..f121533 100644 --- a/lib/tro_flow.py +++ b/lib/tro_flow.py @@ -173,10 +173,8 @@ GENERATED_MARKER = "" COORD_SCALE = 0.1 # Zu-/Ablaufpfeil einer Station in der lagerichtigen Zeichnung: feste Laenge -# (mm) ab dem angehaengten TRO und Faecherwinkel (Grad), wenn mehrere -# Stationen am selben TRO haengen (siehe _station_positionen). +# (mm) ab dem angehaengten TRO (siehe _station_positionen). COORD_PFEIL_LAENGE = 1300 -COORD_PFEIL_FAECHER = 45 # Groesste Luecke (mm) zwischen zwei benachbarten Knotenkoordinaten je Achse in # der lagerichtigen Zeichnung. Groessere Leerstrecken (lange Foerderer ohne @@ -1733,11 +1731,12 @@ def _station_positionen( """Zeichenposition der Stationsbeschriftung (Ende des Zu-/Ablaufpfeils). Feste Pfeillaenge COORD_PFEIL_LAENGE ab dem angehaengten TRO (Beladung: - der TRO danach, Entladung: der TRO davor), Richtung zur echten Station - - so bleibt der Pfeil sichtbar, auch wenn die Station praktisch auf dem TRO - sitzt (Mubea SYS1 186 mm neben TRO04). Haengen mehrere Stationen am selben - TRO, werden sie um COORD_PFEIL_FAECHER Grad aufgefaechert. Station ohne - angehaengten TRO: echte Koordinate.""" + der TRO danach, Entladung: der TRO davor) - so bleibt der Pfeil sichtbar, + auch wenn die Station praktisch auf dem TRO sitzt (Mubea SYS1 186 mm neben + TRO04). Die Richtung haelt den groessten Winkelabstand zu allen Kanten des + Anker-TROs und zu bereits gesetzten Stationspfeilen; die Richtung zur + echten Station entscheidet nur bei Gleichstand. Station ohne angehaengten + TRO: echte Koordinate.""" tro_by_id = {t.tro_id: t for t in analysis.tros} je_tro: dict[str, list[Tro]] = {} ergebnis: dict[str, tuple[float, float]] = {} @@ -1751,17 +1750,38 @@ def _station_positionen( continue je_tro.setdefault(anker, []).append(station) + def _abstand(a: float, b: float) -> float: + d = abs(a - b) % (2 * math.pi) + return min(d, 2 * math.pi - d) + for anker, stationen in je_tro.items(): ax, ay = tro_zeichen[anker] echt = tro_by_id[anker].position - for k, station in enumerate(sorted(stationen, key=lambda s: s.tro_id)): - dx = dy = 0.0 + # Richtungen der Kettenkanten am Anker-TRO - der Pfeil soll von der + # Kette WEG zeigen, nicht auf ihr liegen (Review 2026-10-02: Mubea + # TRO02 -> SYS2 lief fast deckungsgleich mit TRO02 -> TRO13). + belegt: list[float] = [] + for src, dst in analysis.edges: + anderer = dst if src == anker else src if dst == anker else None + if anderer is None or anderer not in tro_zeichen: + continue + ox, oy = tro_zeichen[anderer] + if (ox, oy) != (ax, ay): + belegt.append(math.atan2(oy - ay, ox - ax)) + for station in sorted(stationen, key=lambda s: s.tro_id): + wunsch = -math.pi / 2 if echt and station.position: dx, dy = station.position[0] - echt[0], station.position[1] - echt[1] - if math.hypot(dx, dy) < 1.0: - dx, dy = 0.0, -1.0 - winkel = math.atan2(dy, dx) + math.radians( - (k - (len(stationen) - 1) / 2) * COORD_PFEIL_FAECHER) + if math.hypot(dx, dy) >= 1.0: + wunsch = math.atan2(dy, dx) + # Kandidaten im 15-Grad-Raster: groesster Mindestabstand zu allen + # belegten Richtungen gewinnt, bei Gleichstand die zur echten + # Station naechste Richtung. + kandidaten = [math.radians(g) for g in range(0, 360, 15)] + winkel = max(kandidaten, key=lambda w: ( + round(min((_abstand(w, b) for b in belegt), default=math.pi), 3), + -_abstand(w, wunsch))) + belegt.append(winkel) ergebnis[station.tro_id] = (ax + math.cos(winkel) * COORD_PFEIL_LAENGE, ay + math.sin(winkel) * COORD_PFEIL_LAENGE) return ergebnis