#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ hw_materialfluss.py - Materialfluss-Skizze fuer die HW-Planung aus dem CSV-Export. Aufruf: python hw_materialfluss.py [--layer-cfg ] Gestartet vom Befehl HW_MATERIALFLUSS (Lisp/HwPlanung.lsp, Menue HW Planung -> Materialfluss erzeugen) direkt nach EXPORTCSV; die Ausgabe heisst wie die Zeichnung mit Anhang "_HW.dxf". Alles wird flach in die XY-Ebene (Z = 0) gezeichnet - es geht um den Grundriss des Materialflusses. Gibt es die Datei schon (z.B. mit den Symbolen aus hw_symbole.py), wird nur der Inhalt der HW_*-Layer ersetzt, alles andere bleibt. Was gezeichnet wird: * Kreisel/Eckrad: geschlossene Polylinie als Rechteck um die Laufbahn (Mitte + Abmessungen aus Position/Boundingbox, Ausrichtung aus dem Merkmal "Drehung"), Punktfolge in Drehrichtung (UZS = im Uhrzeigersinn, GUZ = gegen den Uhrzeigersinn). Dazu je ein Pfeil auf den Laengsseiten (Layer hw_richtung), damit die Richtung in der Zeichnung sichtbar ist. * Strecken (VF/GF): je Strecke eine Polylinie in Materialflussrichtung, vom Ausschleuselement (AS) ueber die Separatoren bis zum Einschleuselement (ES), Stuetzpunkte = x/y der Elemente aus der Spalte TrackIds; am Ende ein Richtungspfeil. * Weiche: kleiner Kreis an der Beruehrposition zweier Kreisel. * BTMT Beladung: grosser Kreis auf K1, kleiner Kreis auf K2 (auf der Strecke), dazwischen eine Linie. * BTMT Entladung: kleiner Kreis auf K1 (auf der Strecke), grosser Kreis auf K2, dazwischen eine Linie. Layer und Farben stehen in cfg/layer.cfg, Abschnitt [hw_planung]; fehlt ein Eintrag, gilt die Vorgabe aus LAYER_VORGABEN. """ import argparse import math import os import sys import ezdxf from export_graph import load_elements KREISEL_ARTEN = {"ILS 2.0 Kreisel", "ILS 2.0 Eckrad"} STRECKEN_ARTEN = {"ILS 2.0 Strecke", "ILS 2.0 Strecke - Modul", "ILS 2.0 Gefaellestrecke"} AS_ART = "ILS Ausschleuselement" ES_ART = "ILS Einschleuselement" WEICHE_ARTEN = {"ILS Weiche"} BELADUNG_ARTEN = {"ILS 2.0 BTMT Beladung"} ENTLADUNG_ARTEN = {"ILS 2.0 BTMT Entladung"} # Kreisel-Durchmesser (Laufbahnbreite), falls die Bounding-Box ihn nicht # liefert (schraeg gedrehter Kreisel). KREISEL_DURCHMESSER_MM = 800.0 RADIUS_WEICHE_MM = 100.0 RADIUS_GROSS_MM = 200.0 RADIUS_KLEIN_MM = 80.0 PFEIL_LAENGE_MM = 250.0 PFEIL_BREITE_MM = 150.0 # Schluessel -> (Layername, Farbnummer); ueberschreibbar in cfg/layer.cfg [hw_planung] LAYER_VORGABEN = { "hw_kreisel": ("HW_Kreisel", 3), "hw_strecke": ("HW_Materialfluss", 5), "hw_weiche": ("HW_Weiche", 1), "hw_beladung": ("HW_Beladung", 4), "hw_entladung": ("HW_Entladung", 6), "hw_richtung": ("HW_Richtung", 2), } # ------------------------------------------------------------------ # Konfiguration # ------------------------------------------------------------------ def layer_cfg_lesen(pfad): """Abschnitt [hw_planung] aus layer.cfg -> {key: (layer, farbe)}. Eigener Mini-Parser statt configparser: layer.cfg ist fuer ssg-load-ini geschrieben (Kommentare mit '#', Werte 'Layername, Farbe'). """ layer = dict(LAYER_VORGABEN) if not pfad or not os.path.isfile(pfad): return layer abschnitt = None with open(pfad, encoding="utf-8", errors="replace") as fh: for zeile in fh: zeile = zeile.split("#", 1)[0].strip() if not zeile: continue if zeile.startswith("[") and zeile.endswith("]"): abschnitt = zeile[1:-1].strip().lower() continue if abschnitt != "hw_planung" or "=" not in zeile: continue key, wert = (t.strip() for t in zeile.split("=", 1)) teile = [t.strip() for t in wert.split(",")] name = teile[0] farbe = LAYER_VORGABEN.get(key, ("", 7))[1] if len(teile) > 1: try: farbe = int(teile[1]) except ValueError: pass if name: layer[key] = (name, farbe) return layer # ------------------------------------------------------------------ # Reine Geometrie (in tests/test_hw_materialfluss.py getestet) # ------------------------------------------------------------------ def _drehen(px, py, winkel_grad): w = math.radians(winkel_grad) c, s = math.cos(w), math.sin(w) return (px * c - py * s, px * s + py * c) def kreisel_rechteck(mitte, masse, drehung_grad, drehrichtung, abstand_m=None): """Eckpunkte (x, y) des Kreisel-Rechtecks in Drehrichtung. Laenge/Breite kommen bei achsparalleler Lage exakt aus der Bounding-Box; bei schraeger Drehung (Bounding-Box nicht mehr die Laufbahn) aus dem Abstand AN8-SP8 plus Durchmesser. UZS -> Punkte im Uhrzeigersinn, sonst gegen den Uhrzeigersinn. """ dx, dy = masse["dx"], masse["dy"] w = (drehung_grad or 0.0) % 360.0 if abs(w % 90.0) < 1e-6 or abs(w % 90.0 - 90.0) < 1e-6: laenge, breite = max(dx, dy), min(dx, dy) else: breite = KREISEL_DURCHMESSER_MM laenge = (abstand_m or 0.0) * 1000.0 + breite hl, hb = laenge / 2.0, breite / 2.0 # gegen den Uhrzeigersinn im lokalen KS (Laengsachse = lokales X) lokal = [(-hl, -hb), (hl, -hb), (hl, hb), (-hl, hb)] if (drehrichtung or "").upper() == "UZS": lokal.reverse() cx, cy = mitte["x"], mitte["y"] punkte = [] for px, py in lokal: rx, ry = _drehen(px, py, drehung_grad or 0.0) punkte.append((cx + rx, cy + ry)) return punkte def pfeil(spitze, richtung, laenge=PFEIL_LAENGE_MM, breite=PFEIL_BREITE_MM): """Offener Pfeilkopf (3 Punkte) mit Spitze in `spitze`, zeigend in `richtung`.""" rx, ry = richtung n = math.hypot(rx, ry) if n < 1e-9: return None rx, ry = rx / n, ry / n bx, by = spitze[0] - rx * laenge, spitze[1] - ry * laenge qx, qy = -ry * breite / 2.0, rx * breite / 2.0 return [(bx + qx, by + qy), spitze, (bx - qx, by - qy)] def kreisel_pfeile(punkte): """Richtungspfeile auf der Mitte der beiden laengeren Rechteckseiten.""" pfeile = [] n = len(punkte) seiten = [(punkte[i], punkte[(i + 1) % n]) for i in range(n)] max_l = max(math.dist(a, b) for a, b in seiten) for a, b in seiten: if math.dist(a, b) >= max_l - 1e-6: mitte = ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0) p = pfeil(mitte, (b[0] - a[0], b[1] - a[1])) if p: pfeile.append(p) return pfeile def strecken_pfad(strecke, nach_id): """Stuetzpunkte (x, y) einer Strecke in Materialflussrichtung (AS -> ES). Grundlage ist die Spalte TrackIds (AS, Separatoren, ES in Kettenfolge). Steht das ES vorn, wird umgedreht. Unbekannte IDs werden uebersprungen. """ ids = [t.strip() for t in (strecke.get("trackids") or "").split(",") if t.strip()] glieder = [nach_id[i] for i in ids if i in nach_id] if len(glieder) < 2: return [] if glieder[0]["teileart"] == ES_ART or glieder[-1]["teileart"] == AS_ART: glieder.reverse() punkte = [] for g in glieder: p = (g["mitte"]["x"], g["mitte"]["y"]) if not punkte or math.dist(punkte[-1], p) > 1e-6: punkte.append(p) return punkte # ------------------------------------------------------------------ # DXF schreiben # ------------------------------------------------------------------ def _xy(punkt): return (punkt[0], punkt[1]) if punkt else None # Markierung aller erzeugten Materialfluss-Objekte - daran erkennt LISP sie, # wenn die Ausgabe in die AKTUELLE Zeichnung geht (HwPlanung.lsp, # hw-alte-ausgabe), und ein Folgelauf, was er ersetzen darf. XDATA_APP = "SSG_HW_MF" def hw_dokument_oeffnen(pfad, einheitenlos=False): """Vorhandene Ausgabe-DXF oeffnen oder neu anlegen (R2018, mm). Materialfluss (dieses Skript) und Symbole (hw_symbole.py) schreiben in DIESELBE Datei und ersetzen dort nur ihren eigenen Anteil - was der Anwender ergaenzt hat (Renamer-Rahmen, Polylinien), bleibt erhalten. einheitenlos: $INSUNITS = 0 - fuer die Zwischendatei, die LISP als Block in die aktuelle Zeichnung einfuegt; mit Einheit wuerde BricsCAD sie beim Einfuegen in eine Zeichnung mit anderer Einheit skalieren. """ if os.path.isfile(pfad): doc = ezdxf.readfile(pfad) else: doc = ezdxf.new("R2018", setup=True) doc.header["$INSUNITS"] = 4 # mm if einheitenlos: doc.header["$INSUNITS"] = 0 return doc def _ist_materialfluss(e, eigene_layer): return e.has_xdata(XDATA_APP) or e.dxf.layer in eigene_layer def zeichnen(elemente, ausgabe, layer, einheitenlos=False): doc = hw_dokument_oeffnen(ausgabe, einheitenlos) msp = doc.modelspace() # Materialfluss des vorigen Laufs entfernen (markiert oder - aeltere # Dateien ohne Markierung - auf den HW-Layern) eigene = {name for name, _farbe in layer.values()} for e in [e for e in msp if _ist_materialfluss(e, eigene)]: msp.delete_entity(e) vorher = {e.dxf.handle for e in msp} for name, farbe in layer.values(): if name not in doc.layers: doc.layers.add(name, color=farbe) def ly(key): return {"layer": layer[key][0]} nach_id = {e["teileid"]: e for e in elemente if e["teileid"]} zaehler = {"kreisel": 0, "strecken": 0, "weichen": 0, "beladung": 0, "entladung": 0} warnungen = [] for el in elemente: art = el["teileart"] m = el["merkmale"] if art in KREISEL_ARTEN: abstand = None for k, v in m.items(): if k.startswith("Abstand"): try: abstand = float(str(v).replace(",", ".")) except ValueError: pass try: drehung = float(m.get("Drehung") or 0.0) except (TypeError, ValueError): drehung = 0.0 punkte = kreisel_rechteck(el["mitte"], el["masse"], drehung, m.get("Drehrichtung"), abstand) msp.add_lwpolyline(punkte, close=True, dxfattribs=ly("hw_kreisel")) for p in kreisel_pfeile(punkte): msp.add_lwpolyline(p, dxfattribs=ly("hw_richtung")) zaehler["kreisel"] += 1 elif art in STRECKEN_ARTEN: punkte = strecken_pfad(el, nach_id) if len(punkte) < 2: warnungen.append(f"{el['teileid']} {el['bezeichnung']}: keine Track-Punkte") continue msp.add_lwpolyline(punkte, dxfattribs=ly("hw_strecke")) p = pfeil(punkte[-1], (punkte[-1][0] - punkte[-2][0], punkte[-1][1] - punkte[-2][1])) if p: msp.add_lwpolyline(p, dxfattribs=ly("hw_richtung")) zaehler["strecken"] += 1 elif art in WEICHE_ARTEN: msp.add_circle((el["mitte"]["x"], el["mitte"]["y"]), RADIUS_WEICHE_MM, dxfattribs=ly("hw_weiche")) zaehler["weichen"] += 1 elif art in BELADUNG_ARTEN or art in ENTLADUNG_ARTEN: k1, k2 = _xy(el["ks_ein"]), _xy(el["ks_aus"]) if not (k1 and k2): warnungen.append(f"{el['teileid']} {el['bezeichnung']}: K1/K2 fehlt") continue if art in BELADUNG_ARTEN: msp.add_circle(k1, RADIUS_GROSS_MM, dxfattribs=ly("hw_beladung")) msp.add_circle(k2, RADIUS_KLEIN_MM, dxfattribs=ly("hw_beladung")) msp.add_line(k1, k2, dxfattribs=ly("hw_beladung")) zaehler["beladung"] += 1 else: msp.add_circle(k1, RADIUS_KLEIN_MM, dxfattribs=ly("hw_entladung")) msp.add_circle(k2, RADIUS_GROSS_MM, dxfattribs=ly("hw_entladung")) msp.add_line(k1, k2, dxfattribs=ly("hw_entladung")) zaehler["entladung"] += 1 if XDATA_APP not in doc.appids: doc.appids.new(XDATA_APP) for e in msp: if e.dxf.handle not in vorher: e.set_xdata(XDATA_APP, [(1000, "materialfluss")]) doc.saveas(ausgabe) return zaehler, warnungen def main(argv=None): ap = argparse.ArgumentParser(description=__doc__.split("\n")[1]) ap.add_argument("csv") ap.add_argument("ausgabe") ap.add_argument("--layer-cfg", default=None, help="Pfad zu layer.cfg (Vorgabe: $DXFM_CFG/layer.cfg)") ap.add_argument("--einheitenlos", action="store_true", help="$INSUNITS = 0 (Zwischendatei fuer die aktuelle Zeichnung)") args = ap.parse_args(argv) cfg = args.layer_cfg if cfg is None and os.environ.get("DXFM_CFG"): cfg = os.path.join(os.environ["DXFM_CFG"], "layer.cfg") elemente = load_elements(args.csv) try: zaehler, warnungen = zeichnen(elemente, args.ausgabe, layer_cfg_lesen(cfg), args.einheitenlos) except PermissionError: print(f"FEHLER: {args.ausgabe} ist gesperrt (in BricsCAD geoeffnet?) - bitte schliessen.") return 1 print(f"HW-Materialfluss geschrieben: {args.ausgabe}") print(" " + ", ".join(f"{k}: {v}" for k, v in zaehler.items())) for w in warnungen: print(f" WARNUNG: {w}") return 0 if __name__ == "__main__": sys.exit(main())