- 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>
347 lines
13 KiB
Python
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())
|