Files
dxfmakros/lib/hw_materialfluss.py
T
Michael StanglandClaude Opus 5.5 8abb6b3c65 feat: HW Planung - Symbole erzeugen, Nummerierung, Ausgabeziel-Dialog
- Symbole erzeugen (HW_SYMBOLE, Einzelbefehle HW_SYM_*): Bauteile nach
  cfg/io_regeln.json (reduzierte Kopie aus kabellaengen), Symbole aus
  cfg/hw_symbole.json katalog-identisch nachgebaut (lib/hw_symbole.py),
  REALE_POSITION mit x auf dem Bauteil, Paare uebereinander, Ausweichen
  in y ohne Ueberlappung
- Symbole nummerieren (HW_NUMMERIEREN): lib/create_numbers.py samt
  error_collector.py und cfg/enumerate.cfg aus kabellaengen uebernommen
- Ausgabeziel per Dialog dcl/hw_ausgabe.dcl: aktuelle Zeichnung,
  bestehende Datei ergaenzen oder neue Datei
- Materialfluss mit XDATA SSG_HW_MF, ergaenzt vorhandene Datei
- Doku doc/hw_planung.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:39:37 +01:00

347 lines
13 KiB
Python

#!/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 <export.csv> <ausgabe_HW.dxf> [--layer-cfg <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())