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>
This commit is contained in:
Michael StanglandClaude Opus 5.5 committed 2026-11-03 16:39:37 +01:00
1 parent d2729140ff
commit 8abb6b3c65
20 files changed
+4176 -183

No files matched your search

+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Tests fuer lib/create_numbers.py (Nummerierung der Symbole entlang Renamer-Bereichen).
Uebernommen aus kabellaengen/lib/create_numbers_test.py.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
from create_numbers import sort_symbols_by_direction # noqa: E402
def create_test_symbols():
"""
Erstellt 16 Test-Symbole auf einem 4x4 Raster.
Raster-Layout (Y-Achse nach oben, wie in DXF üblich):
Y=300: (0,300) (100,300) (200,300) (300,300) <- Oben
Y=200: (0,200) (100,200) (200,200) (300,200)
Y=100: (0,100) (100,100) (200,100) (300,100)
Y=0: (0,0) (100,0) (200,0) (300,0) <- Unten
X=0 X=100 X=200 X=300
^ ^
Links Rechts
"""
symbols = []
symbol_id = 1
# Erstelle 4x4 Raster
for y in [0, 100, 200, 300]:
for x in [0, 100, 200, 300]:
symbols.append({
'id': symbol_id,
'position': (x, y),
'entity': None, # Für den Test nicht benötigt
'attributes': {'IO': f'SYM-{symbol_id}@@'},
'layer': 'TEST_LAYER'
})
symbol_id += 1
return symbols
class TestEntitySorting(unittest.TestCase):
"""Tests for running all combinations of sort_symbols_by_direction."""
@classmethod
def setUpClass(cls):
"""Wird einmal vor allen Tests ausgeführt."""
print("="*70)
print("Test Suite: sort_symbols_by_direction")
print("="*70)
print()
sys.stdout.flush()
def test_top_bottom_left_right(self):
"""
Test TOP_BOTTOM/LEFT_RIGHT: Von oben nach unten, in jeder Zeile von links nach rechts
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "TOP_BOTTOM/LEFT_RIGHT")
# Erwartete Reihenfolge: Zeile Y=300, dann Y=200, dann Y=100, dann Y=0
# In jeder Zeile: X=0, X=100, X=200, X=300
expected_positions = [
(0, 300), (100, 300), (200, 300), (300, 300), # Zeile Y=300 (oben)
(0, 200), (100, 200), (200, 200), (300, 200), # Zeile Y=200
(0, 100), (100, 100), (200, 100), (300, 100), # Zeile Y=100
(0, 0), (100, 0), (200, 0), (300, 0) # Zeile Y=0 (unten)
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_top_bottom_right_left(self):
"""
Test TOP_BOTTOM/RIGHT_LEFT: Von oben nach unten, in jeder Zeile von rechts nach links
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "TOP_BOTTOM/RIGHT_LEFT")
# Erwartete Reihenfolge: Zeile Y=300, dann Y=200, dann Y=100, dann Y=0
# In jeder Zeile: X=300, X=200, X=100, X=0
expected_positions = [
(300, 300), (200, 300), (100, 300), (0, 300), # Zeile Y=300 (oben)
(300, 200), (200, 200), (100, 200), (0, 200), # Zeile Y=200
(300, 100), (200, 100), (100, 100), (0, 100), # Zeile Y=100
(300, 0), (200, 0), (100, 0), (0, 0) # Zeile Y=0 (unten)
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_bottom_top_left_right(self):
"""
Test BOTTOM_TOP/LEFT_RIGHT: Von unten nach oben, in jeder Zeile von links nach rechts
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "BOTTOM_TOP/LEFT_RIGHT")
# Erwartete Reihenfolge: Zeile Y=0, dann Y=100, dann Y=200, dann Y=300
# In jeder Zeile: X=0, X=100, X=200, X=300
expected_positions = [
(0, 0), (100, 0), (200, 0), (300, 0), # Zeile Y=0 (unten)
(0, 100), (100, 100), (200, 100), (300, 100), # Zeile Y=100
(0, 200), (100, 200), (200, 200), (300, 200), # Zeile Y=200
(0, 300), (100, 300), (200, 300), (300, 300) # Zeile Y=300 (oben)
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_bottom_top_right_left(self):
"""
Test BOTTOM_TOP/RIGHT_LEFT: Von unten nach oben, in jeder Zeile von rechts nach links
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "BOTTOM_TOP/RIGHT_LEFT")
# Erwartete Reihenfolge: Zeile Y=0, dann Y=100, dann Y=200, dann Y=300
# In jeder Zeile: X=300, X=200, X=100, X=0
expected_positions = [
(300, 0), (200, 0), (100, 0), (0, 0), # Zeile Y=0 (unten)
(300, 100), (200, 100), (100, 100), (0, 100), # Zeile Y=100
(300, 200), (200, 200), (100, 200), (0, 200), # Zeile Y=200
(300, 300), (200, 300), (100, 300), (0, 300) # Zeile Y=300 (oben)
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_left_right(self):
"""
Test LEFT_RIGHT: Spaltenweise von links nach rechts
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "LEFT_RIGHT")
# Spalte für Spalte: X=0, dann X=100, dann X=200, dann X=300
# Innerhalb jeder Spalte: Y aufsteigend (0, 100, 200, 300)
expected_positions = [
(0, 0), (0, 100), (0, 200), (0, 300), # Spalte X=0
(100, 0), (100, 100), (100, 200), (100, 300), # Spalte X=100
(200, 0), (200, 100), (200, 200), (200, 300), # Spalte X=200
(300, 0), (300, 100), (300, 200), (300, 300) # Spalte X=300
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_right_left(self):
"""
Test RIGHT_LEFT: Spaltenweise von rechts nach links
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "RIGHT_LEFT")
# Spalte für Spalte: X=300, dann X=200, dann X=100, dann X=0
# Innerhalb jeder Spalte: Y aufsteigend (0, 100, 200, 300)
expected_positions = [
(300, 0), (300, 100), (300, 200), (300, 300), # Spalte X=300
(200, 0), (200, 100), (200, 200), (200, 300), # Spalte X=200
(100, 0), (100, 100), (100, 200), (100, 300), # Spalte X=100
(0, 0), (0, 100), (0, 200), (0, 300) # Spalte X=0
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_top_bottom(self):
"""
Test TOP_BOTTOM: Default ist TOP_BOTTOM/LEFT_RIGHT
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "TOP_BOTTOM")
# Sollte identisch mit TOP_BOTTOM/LEFT_RIGHT sein
expected_positions = [
(0, 300), (100, 300), (200, 300), (300, 300),
(0, 200), (100, 200), (200, 200), (300, 200),
(0, 100), (100, 100), (200, 100), (300, 100),
(0, 0), (100, 0), (200, 0), (300, 0)
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_bottom_top(self):
"""
Test BOTTOM_TOP: Default ist BOTTOM_TOP/LEFT_RIGHT
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "BOTTOM_TOP")
# Sollte identisch mit BOTTOM_TOP/LEFT_RIGHT sein
expected_positions = [
(0, 0), (100, 0), (200, 0), (300, 0),
(0, 100), (100, 100), (200, 100), (300, 100),
(0, 200), (100, 200), (200, 200), (300, 200),
(0, 300), (100, 300), (200, 300), (300, 300)
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_left_right_top_bottom(self):
"""
Test LEFT_RIGHT/TOP_BOTTOM: Umgekehrte Reihenfolge, identisch mit TOP_BOTTOM/LEFT_RIGHT
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "LEFT_RIGHT/TOP_BOTTOM")
expected_positions = [
(0, 300), (0, 200), (0, 100), (0, 0),
(100, 300), (100, 200), (100, 100), (100, 0),
(200, 300), (200, 200), (200, 100), (200, 0),
(300, 300), (300, 200), (300, 100), (300, 0)
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_right_left_top_bottom(self):
"""
Test RIGHT_LEFT/TOP_BOTTOM: Umgekehrte Reihenfolge, identisch mit TOP_BOTTOM/RIGHT_LEFT
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "RIGHT_LEFT/TOP_BOTTOM")
expected_positions = [
(300, 300), (300, 200), (300, 100), (300, 0),
(200, 300), (200, 200), (200, 100), (200, 0),
(100, 300), (100, 200), (100, 100), (100, 0),
(0, 300), (0, 200), (0, 100), (0, 0)
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_left_right_bottom_top(self):
"""
Test LEFT_RIGHT/BOTTOM_TOP: Umgekehrte Reihenfolge, identisch mit BOTTOM_TOP/LEFT_RIGHT
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "LEFT_RIGHT/BOTTOM_TOP")
expected_positions = [
(0, 0), (0, 100), (0, 200), (0, 300),
(100, 0), (100, 100), (100, 200), (100, 300),
(200, 0), (200, 100), (200, 200), (200, 300),
(300, 0), (300, 100), (300, 200), (300, 300),
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_right_left_bottom_top(self):
"""
Test RIGHT_LEFT/BOTTOM_TOP: Umgekehrte Reihenfolge, identisch mit BOTTOM_TOP/RIGHT_LEFT
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "RIGHT_LEFT/BOTTOM_TOP")
# Sollte identisch mit BOTTOM_TOP/RIGHT_LEFT sein
expected_positions = [
(300, 0), (300, 100), (300, 200), (300, 300),
(200, 0), (200, 100), (200, 200), (200, 300),
(100, 0), (100, 100), (100, 200), (100, 300),
(0, 0), (0, 100), (0, 200), (0, 300),
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
def test_fallback(self):
"""
Test Fallback (unbekannte DIRECTION): Default ist TOP_BOTTOM/LEFT_RIGHT
"""
symbols = create_test_symbols()
sorted_symbols = sort_symbols_by_direction(symbols, "UNKNOWN_DIRECTION")
# Sollte identisch mit TOP_BOTTOM/LEFT_RIGHT sein
expected_positions = [
(0, 300), (100, 300), (200, 300), (300, 300),
(0, 200), (100, 200), (200, 200), (300, 200),
(0, 100), (100, 100), (200, 100), (300, 100),
(0, 0), (100, 0), (200, 0), (300, 0)
]
actual_positions = [s['position'] for s in sorted_symbols]
assert actual_positions == expected_positions, f"Fehler! Erwartet: {expected_positions}, Erhalten: {actual_positions}"
# print("\n16 Symbole auf 4x4 Raster:")
# print(" X-Koordinaten: 0, 100, 200, 300")
# print(" Y-Koordinaten: 0, 100, 200, 300")
# print("\nUnterstützte Richtungen (beide Reihenfolgen):")
# print(" - TOP_BOTTOM/LEFT_RIGHT = LEFT_RIGHT/TOP_BOTTOM")
# print(" - TOP_BOTTOM/RIGHT_LEFT = RIGHT_LEFT/TOP_BOTTOM")
# print(" - BOTTOM_TOP/LEFT_RIGHT = LEFT_RIGHT/BOTTOM_TOP")
# print(" - BOTTOM_TOP/RIGHT_LEFT = RIGHT_LEFT/BOTTOM_TOP")
# print("\nEinfache Richtungen:")
# print(" - LEFT_RIGHT: Spaltenweise links→rechts (sortiert nach X, dann Y)")
# print(" - RIGHT_LEFT: Spaltenweise rechts→links (sortiert nach -X, dann Y)")
# print(" - TOP_BOTTOM → TOP_BOTTOM/LEFT_RIGHT (zeilenweise)")
# print(" - BOTTOM_TOP → BOTTOM_TOP/LEFT_RIGHT (zeilenweise)")
# # Test kombinierte Richtungen (Y/X Reihenfolge)
# test_top_bottom_left_right()
# test_top_bottom_right_left()
# test_bottom_top_left_right()
# test_bottom_top_right_left()
# # Test einfache Richtungen
# test_left_right()
# test_right_left()
# test_top_bottom()
# test_bottom_top()
# # Test umgekehrte Reihenfolge (X/Y Reihenfolge)
# test_left_right_top_bottom()
# test_right_left_top_bottom()
# test_left_right_bottom_top()
# test_right_left_bottom_top()
# # Test Fallback
# test_fallback()
# print("\n" + "="*70)
# print("✓✓✓ ALLE 13 TESTS BESTANDEN ✓✓✓")
# print("="*70)
# print("\nZusammenfassung:")
# print(" - Alle 4 kombinierten Richtungen (Y/X) funktionieren")
# print(" - Alle 4 kombinierten Richtungen (X/Y) funktionieren")
# print(" - Alle 4 einfachen Richtungen mappen korrekt")
# print(" - Fallback funktioniert")
if __name__ == '__main__':
unittest.main()
+37
View File
@@ -92,3 +92,40 @@ def test_csv_nach_dxf(tmp_path):
msp = ezdxf.readfile(str(dxf_pfad)).modelspace()
assert len(msp.query('LWPOLYLINE[layer=="HW_Kreisel"]')) == 1
assert len(msp.query('CIRCLE[layer=="HW_Weiche"]')) == 1
def _csv_mit_kreisel(tmp_path):
kopf = ("Elementnummer;TeileArt;TeileId;Bezeichnung;Planquadrat;Anzahl;Position;"
"Boundingbox;Insertpoint;K1;K2;K3;K4;Nachbarn;TrackIds;Fehler;Warnungen;Merkmale")
zeile = ('1;"ILS 2.0 Kreisel";"0001";"K1";"";1;"0.00, 0.00, 0.00";"5800.00, 800.00, 1.00";'
'"";"";"";"";"";"";"";"";"";{"Drehrichtung": "UZS", "Drehung": 0}')
csv_pfad = tmp_path / "export.csv"
csv_pfad.write_text(kopf + "\n" + zeile + "\n", encoding="utf-8")
return csv_pfad
def test_materialfluss_markiert_und_ersetzt_nur_eigenes(tmp_path):
"""Erzeugte Objekte tragen XDATA SSG_HW_MF; ein zweiter Lauf ersetzt sie,
laesst aber fremde Geometrie (z.B. Renamer-Rahmen) stehen."""
csv_pfad = _csv_mit_kreisel(tmp_path)
dxf_pfad = tmp_path / "Test_HW.dxf"
assert hw.main([str(csv_pfad), str(dxf_pfad), "--layer-cfg", ""]) == 0
doc = ezdxf.readfile(str(dxf_pfad))
msp = doc.modelspace()
assert msp and all(e.has_xdata(hw.XDATA_APP) for e in msp)
msp.add_line((0, 0), (1, 1), dxfattribs={"layer": "ILS_RENAMER"})
doc.saveas(str(dxf_pfad))
assert hw.main([str(csv_pfad), str(dxf_pfad), "--layer-cfg", ""]) == 0
msp = ezdxf.readfile(str(dxf_pfad)).modelspace()
assert len(msp.query('LWPOLYLINE[layer=="HW_Kreisel"]')) == 1
assert len(msp.query('LINE[layer=="ILS_RENAMER"]')) == 1
def test_materialfluss_einheitenlos(tmp_path):
"""Zwischendatei fuer die aktuelle Zeichnung: $INSUNITS = 0, sonst
skaliert BricsCAD sie beim Einfuegen."""
csv_pfad = _csv_mit_kreisel(tmp_path)
dxf_pfad = tmp_path / "Test_HW_tmp.dxf"
assert hw.main([str(csv_pfad), str(dxf_pfad), "--layer-cfg", "", "--einheitenlos"]) == 0
assert ezdxf.readfile(str(dxf_pfad)).header["$INSUNITS"] == 0
+278
View File
@@ -0,0 +1,278 @@
"""Tests fuer lib/hw_symbole.py, cfg/hw_symbole.json und die Kette
Symbole -> Renamer -> create_numbers."""
import json
import os
import sys
import ezdxf
import pytest
ROOT = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, os.path.join(ROOT, "lib"))
import create_numbers # noqa: E402
import hw_symbole as hs # noqa: E402
KONFIG = {
"textstile": {"standard": "ARIAL", "schriften": {"ARIAL": "arial.ttf"}},
"vorlagen": {"klein": {"rahmen": [750.0, 270.0], "attribute": [
{"tag": "IO", "x": 100.0, "y": 130.0, "hoehe": 125.0, "farbe": "symbol"},
{"tag": "VERW", "x": 20.0, "y": 20.0, "hoehe": 70.0},
{"tag": "BEZEICHNUNG", "x": 0.0, "y": -80.0, "hoehe": 60.0, "unsichtbar": True},
{"tag": "REALE_POSITION", "x": 715.0, "y": 110.0, "hoehe": 50.0,
"ausrichtung": "MC", "wert": "x"}]}},
"symbole": {
"SYM_VENTIL": {"vorlage": "klein", "layer": "ILS_Ausgang", "farbe": 3,
"werte": {"IO": "MB0", "VERW": "Sep", "BEZEICHNUNG": "Separator (MB0)"}},
"SYM_SENSOR": {"vorlage": "klein", "layer": "ILS_Eingang",
"werte": {"IO": "BG0", "VERW": "In Sep"}}},
}
def _plan(praefixe, symbole):
return {"praefixe": praefixe, "symbole": symbole}
def _sym(praefix, block, x, y, rolle="r"):
return {"praefix": praefix, "io": f"{praefix}1@@@", "block": block,
"x": x, "y": y, "rolle": rolle, "key": f"{x}", "bauteil": "separator"}
def test_attribut_werte_ersetzt_platzhalter():
vorgaben = {"IO": "MA0", "VERW": "ILS-KR-MA0", "BEZEICHNUNG": "Motor MA0", "ARTINR": "1"}
assert hs.attribut_werte(vorgaben, "MA1@@@") == {
"IO": "MA1@@@", "VERW": "ILS-KR-MA1@@@", "BEZEICHNUNG": "Motor MA1@@@"}
assert hs.attribut_werte({"IO": ""}, "BX1@@@") == {"IO": "BX1@@@"}
def test_symbol_vorgaben_vorlage_und_symbol():
werte = hs.symbol_vorgaben(KONFIG["symbole"]["SYM_SENSOR"], KONFIG["vorlagen"]["klein"])
assert werte == {"IO": "BG0", "VERW": "In Sep", "BEZEICHNUNG": "", "REALE_POSITION": "x"}
def test_block_aufbauen_aus_konfiguration():
doc = ezdxf.new("R2018")
hs.block_aufbauen(doc, "SYM_VENTIL", KONFIG)
blk = doc.blocks.get("SYM_VENTIL")
rahmen = blk.query("LWPOLYLINE")[0]
assert rahmen.dxf.layer == "ILS_Ausgang" and rahmen.closed
attdefs = {a.dxf.tag: a for a in blk.query("ATTDEF")}
assert list(attdefs) == ["IO", "VERW", "BEZEICHNUNG", "REALE_POSITION"]
assert attdefs["IO"].dxf.text == "MB0" and attdefs["IO"].dxf.color == 3
assert attdefs["BEZEICHNUNG"].is_invisible
assert attdefs["REALE_POSITION"].dxf.text == "x"
assert "ARIAL" in doc.styles
# Erneuter Aufbau ersetzt den Inhalt statt ihn zu verdoppeln
hs.block_aufbauen(doc, "SYM_VENTIL", KONFIG)
assert len(doc.blocks.get("SYM_VENTIL").query("ATTDEF")) == 4
def test_symbole_setzen_und_nur_eigene_praefixe_ersetzen():
doc = ezdxf.new("R2018")
doc.modelspace().add_line((0, 0), (1, 1), dxfattribs={"layer": "HW_Materialfluss"})
plan = _plan(["MB", "BG"], [_sym("MB", "SYM_VENTIL", 0, 0),
_sym("BG", "SYM_SENSOR", 0, 350),
_sym("MB", "FEHLT", 9, 9)])
gesetzt, entfernt, fehlend, ueberlappt = hs.symbole_setzen(doc, plan, KONFIG)
assert gesetzt == {"MB": 1, "BG": 1} and entfernt == 0 and fehlend == ["FEHLT"]
assert ueberlappt == 0
ventil = doc.modelspace().query('INSERT[name=="SYM_VENTIL"]')[0]
attr = {a.dxf.tag: a.dxf.text for a in ventil.attribs}
assert ventil.dxf.layer == "ILS_Ausgang"
assert attr["IO"] == "MB1@@@" and attr["BEZEICHNUNG"] == "Separator (MB1@@@)"
assert attr["REALE_POSITION"] == "x"
assert hs.symbol_xdata(ventil)[:2] == ("MB", "r")
# Nur Ventile neu: Sensor und fremde Geometrie bleiben stehen
gesetzt, entfernt, _, _ = hs.symbole_setzen(
doc, _plan(["MB"], [_sym("MB", "SYM_VENTIL", 100, 0),
_sym("BG", "SYM_SENSOR", 100, 0)]), KONFIG)
msp = doc.modelspace()
assert entfernt == 1 and gesetzt == {"MB": 1}
assert len(msp.query('INSERT[name=="SYM_VENTIL"]')) == 1
assert len(msp.query('INSERT[name=="SYM_SENSOR"]')) == 1
assert len(msp.query("LINE")) == 1
def test_echte_konfiguration_deckt_regelwerk_ab():
"""Jedes Symbol aus cfg/io_regeln.json ist in cfg/hw_symbole.json definiert,
jede Vorlage existiert, und jedes Symbol baut sich fehlerfrei auf."""
with open(os.path.join(ROOT, "cfg", "io_regeln.json"), encoding="utf-8") as fh:
regeln = json.load(fh)
with open(os.path.join(ROOT, "cfg", "hw_symbole.json"), encoding="utf-8") as fh:
konfig = json.load(fh)
bloecke = set()
for r in regeln["rollen"]:
bloecke.add(r["block"])
bloecke.update(v["block"] for v in r.get("block_nach_name", []))
assert bloecke - set(konfig["symbole"]) == set()
doc = ezdxf.new("R2018")
for name, sdef in konfig["symbole"].items():
assert sdef["vorlage"] in konfig["vorlagen"]
assert "IO" in sdef["werte"]
hs.block_aufbauen(doc, name, konfig)
def _renamer_pfad(doc, punkte):
"""Renamer-Block mit Polylinie (POLYLINE_PATH) fuer BG und MB."""
blk = doc.blocks.new("RENAMER_TEST")
blk.add_lwpolyline(punkte)
werte = {"NAME1": "BG-1@@@", "LAYER_NAME1": "ILS_Eingang",
"NAME2": "MB-1@@@", "LAYER_NAME2": "ILS_Ausgang",
"KENNZEICHNUNG": "=A01+UC0100", "DIRECTION": "POLYLINE_PATH"}
for i, tag in enumerate(werte):
blk.add_attdef(tag, (0, -100 * i))
if "ILS_RENAMER" not in doc.layers:
doc.layers.add("ILS_RENAMER")
ref = doc.modelspace().add_blockref("RENAMER_TEST", (0, 0), dxfattribs={"layer": "ILS_RENAMER"})
ref.add_auto_attribs(werte)
def test_kette_symbole_renamer_nummerierung(tmp_path):
symbole = tmp_path / "hw_symbole.json"
symbole.write_text(json.dumps(KONFIG), encoding="utf-8")
hw = str(tmp_path / "Anlage_HW.dxf")
plan_pfad = tmp_path / "plan.json"
# Zwei Separatoren (Ziel auf y=0), der rechte zuerst im Plan - nummeriert
# wird entlang der Polylinie
plan_pfad.write_text(json.dumps(_plan(["MB", "BG"], [
_sym("MB", "SYM_VENTIL", 5000, 0),
_sym("BG", "SYM_SENSOR", 5000, 0),
_sym("MB", "SYM_VENTIL", 1000, 0),
_sym("BG", "SYM_SENSOR", 1000, 0)])), encoding="utf-8")
assert hs.main([str(plan_pfad), hw, "--symbole", str(symbole)]) == 0
doc = ezdxf.readfile(hw)
# create_numbers verlangt fuer POLYLINE_PATH mind. 4 Punkte und kein
# Rechteck (offen mit genau 4 Punkten gilt als Rechteck) - also 5 Punkte
_renamer_pfad(doc, [(-1000, 0), (1000, 0), (3000, 0), (5000, 0), (7000, 0)])
doc.saveas(hw)
cfg = tmp_path / "enumerate.cfg"
cfg.write_text("[Layers]\nILS_RENAMER\n[Parameters]\npolyline_max_distance = 500.0\n",
encoding="utf-8")
info = tmp_path / "info.json"
rc = create_numbers.main(["--filename", hw, "--config", str(cfg), "--dryrun",
"--info", str(info), "--errorfile", str(tmp_path / "err.json")])
assert rc == 0
umbenannt = json.loads(info.read_text(encoding="utf-8"))["renamed_symbols"]
# Einfuegepunkt = Ziel - Marke (715): links das Paar am Ziel x=1000
links = {r["new_value"] for r in umbenannt if round(r["position"]["x"]) == 1000 - 715}
rechts = {r["new_value"] for r in umbenannt if round(r["position"]["x"]) == 5000 - 715}
assert {"MB1001", "BG1002"} <= links or {"BG1001", "MB1002"} <= links
assert {"MB1003", "BG1004"} <= rechts or {"BG1003", "MB1004"} <= rechts
assert any(r["new_value"] in ("Separator (MB1001)", "Separator (MB1002)") for r in umbenannt)
# Katalog der ILS-Symbole im Nachbar-Repo kabellaengen (Quelle von hw_symbole.json)
KATALOG = os.environ.get("ILS_SYMBOLE_KATALOG", os.path.join(
ROOT, "..", "kabellaengen", "doc", "Symbole", "ILS-Symbole.dxf"))
def _signatur(blk):
"""Vergleichbare Beschreibung einer Blockdefinition: Rahmen, Schraffur, ATTDEFs."""
attdefs = {}
for a in blk.query("ATTDEF"):
h, v = a.dxf.get("halign", 0), a.dxf.get("valign", 0)
p = a.dxf.align_point if (h or v) else a.dxf.insert
attdefs[a.dxf.tag] = {
"x": round(p.x, 1), "y": round(p.y, 1), "hoehe": a.dxf.height,
"unsichtbar": a.is_invisible, "layer": a.dxf.layer, "ausrichtung": (h, v),
"stil": a.dxf.get("style", "ARIAL"), "breite": round(a.dxf.get("width", 1.0), 2),
"farbe": a.dxf.get("color", 256) or 256, "text": a.dxf.text}
rahmen = sorted({(round(p[0], 1), round(p[1], 1))
for e in blk.query("LWPOLYLINE") for p in e.get_points()})
schraffur = [(h.dxf.pattern_name, h.dxf.pattern_scale, h.dxf.layer, h.dxf.get("color", 256))
for h in blk.query("HATCH")]
return rahmen, schraffur, attdefs
@pytest.mark.skipif(not os.path.isfile(KATALOG), reason="kabellaengen-Katalog nicht gefunden")
def test_symbole_gleichen_dem_katalog():
"""Die aus cfg/hw_symbole.json gebauten Symbole tragen dieselben Blocknamen
wie die Katalogsymbole in kabellaengen - sie muessen darum auch gleich
aussehen (Einfuegepunkt, Rahmen, Lage/Hoehe/Stil aller Attribute, Werte),
sonst gilt beim Mischen beider Fassungen still die falsche."""
with open(os.path.join(ROOT, "cfg", "hw_symbole.json"), encoding="utf-8") as fh:
konfig = json.load(fh)
katalog = ezdxf.readfile(KATALOG)
doc = ezdxf.new("R2018")
abweichungen = []
for name in konfig["symbole"]:
hs.block_aufbauen(doc, name, konfig)
soll_r, soll_s, soll_a = _signatur(katalog.blocks.get(name))
ist_r, ist_s, ist_a = _signatur(doc.blocks.get(name))
if soll_r != ist_r:
abweichungen.append(f"{name}: Rahmen {soll_r} != {ist_r}")
if soll_s != ist_s:
abweichungen.append(f"{name}: Schraffur {soll_s} != {ist_s}")
for tag in sorted(set(soll_a) | set(ist_a)):
s, i = soll_a.get(tag), ist_a.get(tag)
if s is None or i is None:
abweichungen.append(f"{name}.{tag}: nur im {'Katalog' if i is None else 'eigenen Symbol'}")
continue
for k in s:
gleich = abs(s[k] - i[k]) <= 0.11 if k in ("x", "y") else s[k] == i[k]
if not gleich:
abweichungen.append(f"{name}.{tag}.{k}: Katalog {s[k]!r}, eigen {i[k]!r}")
assert abweichungen == []
# ------------------------------------------------------------------
# Platzierung
# ------------------------------------------------------------------
def _geo(x0, y0, x1, y1, mx, my):
return {"box": (x0, y0, x1, y1), "marke": (mx, my)}
OBEN = _geo(0, 0, 750, 270, 750, 135) # wie Katalog-Ausgang
UNTEN = _geo(0, -270, 750, 0, 750, -135) # wie Katalog-Eingang (haengt nach unten)
def test_symbol_geometrie_aus_konfiguration():
with open(os.path.join(ROOT, "cfg", "hw_symbole.json"), encoding="utf-8") as fh:
konfig = json.load(fh)
assert hs.symbol_geometrie(konfig, "ILS_Separator-Ausgang") == {
"box": (0.0, 0.0, 750.0, 270.0), "marke": (750.0, 135.0)}
assert hs.symbol_geometrie(konfig, "ILS_720002003-Separator") == {
"box": (0.0, -270.0, 750.0, 0.0), "marke": (750.0, -135.0)}
def test_platzieren_einzeln_marke_auf_ziel():
(punkte,), ueber = hs.platzieren([((1000.0, 500.0), [OBEN])])
assert ueber == 0
ex, ey = punkte[0]
assert (ex + 750, ey + 135) == (1000.0, 500.0) # Marke genau auf dem Ziel
(punkte,), _ = hs.platzieren([((1000.0, 500.0), [UNTEN])])
assert (punkte[0][0] + 750, punkte[0][1] - 135) == (1000.0, 500.0)
def test_platzieren_paar_uebereinander():
(punkte,), ueber = hs.platzieren([((1000.0, 0.0), [OBEN, UNTEN])])
assert ueber == 0
(x1, y1), (x2, y2) = punkte
# Marken beide mit x auf dem Ziel, Kaesten links daneben, luekenlos
# uebereinander und zusammen um das Ziel zentriert
assert x1 + 750 == 1000.0 and x2 + 750 == 1000.0
assert (y1, y1 + 270) == (0.0, 270.0) # oberer Kasten 0..270
assert (y2 - 270, y2) == (-270.0, 0.0) # unterer Kasten -270..0
def test_platzieren_weicht_in_y_aus():
gruppen = [((1000.0, 0.0), [OBEN, UNTEN]), ((1200.0, 100.0), [OBEN])]
(paar, (einzel,)), ueber = hs.platzieren(gruppen, abstand=20.0, schritt=50.0)
assert ueber == 0
assert einzel[0] + 750 == 1200.0 # x bleibt auf dem Ziel
unten, oben = einzel[1], einzel[1] + 270
assert oben <= -270 - 20 or unten >= 270 + 20 # frei von Paar-Kasten -270..270
def test_platzieren_meldet_ueberlappung_ohne_platz():
gruppen = [((0.0, 0.0), [OBEN]), ((0.0, 0.0), [OBEN])]
_punkte, ueber = hs.platzieren(gruppen, schritt=50.0, max_verschiebung=100.0)
assert ueber == 1
+103 -3
View File
@@ -3595,15 +3595,115 @@
)
)
;; HW-Planung: Ausgabename der Materialfluss-Skizze
(defun tu-tests-hw ()
;; HW-Planung: Ausgabename, JSON-Zahlen, Symbol-Regelwerk (Zuordnung Block ->
;; Bauteil -> Katalogsymbol, IO-Vorlage, Plan + JSON) und das Zuordnen der
;; create_numbers-Ergebnisse
(defun tu-tests-hw ( / _regeln _plan _json _echt _fehlt _eintrag)
(princ "\n[HwPlanung]")
(if (not (car (atoms-family 1 '("HW-DXF-NAME"))))
(tu-record nil "HwPlanung-Modul geladen"
"HwPlanung.lsp nicht geladen - siehe tests/test_unit.scr")
(progn
(tu-eq "hw-dxf-name/dwg" "Mubea_HW.dxf" (hw-dxf-name "Mubea.dwg"))
(tu-eq "hw-dxf-name/punkt" "A.B_HW.dxf" (hw-dxf-name "A.B.dwg")))
(tu-eq "hw-dxf-name/punkt" "A.B_HW.dxf" (hw-dxf-name "A.B.dwg"))
;; Ausgabe-Dialog: Dateiname und Eingabepruefung
(tu-eq "hw-name-ohne-dxf" "Anlage_HW" (hw-name-ohne-dxf " Anlage_HW.DXF "))
(tu-eq "hw-name-ohne-dxf/ohne" "Plan 2" (hw-name-ohne-dxf "Plan 2"))
(tu-true "hw-name-gueltig-p/ok" (hw-name-gueltig-p "Mubea_HW"))
(tu-nil "hw-name-gueltig-p/leer" (hw-name-gueltig-p " "))
(tu-nil "hw-name-gueltig-p/pfad" (hw-name-gueltig-p "C:/x/Mubea"))
(tu-nil "hw-name-gueltig-p/stern" (hw-name-gueltig-p "a*b"))
(tu-eq "hw-neu-pfad" "C:/erg/Mubea_HW.dxf" (hw-neu-pfad "C:/erg" "Mubea_HW.dxf"))
(tu-nil "hw-ausgabe-pruefen/aktuell" (hw-ausgabe-pruefen "aktuell" nil "" nil))
(tu-nil "hw-ausgabe-pruefen/ergaenzen" (hw-ausgabe-pruefen "ergaenzen" T "" nil))
(tu-eq "hw-ausgabe-pruefen/ergaenzen-fehlt" "hw-aus-datei-fehlt"
(hw-ausgabe-pruefen "ergaenzen" nil "" nil))
(tu-nil "hw-ausgabe-pruefen/neu" (hw-ausgabe-pruefen "neu" nil "Neu_HW" nil))
(tu-eq "hw-ausgabe-pruefen/neu-ungueltig" "hw-aus-name-ungueltig"
(hw-ausgabe-pruefen "neu" nil "a?b" nil))
(tu-eq "hw-ausgabe-pruefen/neu-existiert" "hw-aus-name-existiert"
(hw-ausgabe-pruefen "neu" nil "Mubea_HW" T))
(tu-eq "hw-json-zahl/neg" "-12.500" (hw-json-zahl -12.5))
(tu-eq "hw-json-zahl/klein" "0.050" (hw-json-zahl 0.05))
(tu-eq "hw-json-zahl/rund" "1001.000" (hw-json-zahl 1000.9999))
(tu-eq "hw-json-zahl/null" "0.000" (hw-json-zahl -0.0001))
;; Struktur wie ssg-json-parse sie aus cfg/io_regeln.json liefert
(setq _regeln
(list '("io_vorlage" . "1@@@")
(list "rollen"
'(("rolle" . "sep_ventil") ("praefix" . "MB") ("block" . "SYM_MB"))
'(("rolle" . "sep_sensor") ("praefix" . "BG") ("block" . "SYM_BG"))
'(("rolle" . "vario_motor") ("praefix" . "MA") ("block" . "SYM_MA_LI")
("block_nach_name" (("muster" . "*RECHTS*") ("block" . "SYM_MA_RE")))))
(list "bauteile"
'(("bauteil" . "separator") ("bloecke" "Separator_SP*" "S-SP")
("benoetigt" ("sep_ventil") ("sep_sensor")))
'(("bauteil" . "vario") ("bloecke" "Vario_Motorstation*")
("benoetigt" ("vario_motor" "andere_alternative"))))))
(tu-eq "hw-sym-io-vorlage" "MB1@@@" (hw-sym-io-vorlage _regeln "MB"))
(tu-eq "hw-sym-io-vorlage/vorgabe" "BG1@@@" (hw-sym-io-vorlage nil "BG"))
(tu-eq "hw-sym-bauteil-fuer-block/sep" "separator"
(ssg-json-get (hw-sym-bauteil-fuer-block _regeln "separator_sp_3D") "bauteil"))
(tu-nil "hw-sym-bauteil-fuer-block/fremd" (hw-sym-bauteil-fuer-block _regeln "AN8_3D"))
(tu-eq "hw-sym-muster" "SEPARATOR_SP*,S-SP,VARIO_MOTORSTATION*" (hw-sym-muster _regeln))
(tu-eq "hw-sym-block-fuer/rechts" "SYM_MA_RE"
(hw-sym-block-fuer (hw-sym-rolle _regeln "vario_motor") "Vario_Motorstation_500mm_rechts_3D"))
(tu-eq "hw-sym-block-fuer/links" "SYM_MA_LI"
(hw-sym-block-fuer (hw-sym-rolle _regeln "vario_motor") "Vario_Motorstation_500mm_links_3D"))
(tu-eq "hw-sym-ziel/flach" '(1000.0 2000.0 0.0) (hw-sym-ziel '(1000.0 2000.0 5.0)))
;; Plan: zwei Separatoren (Sortierung nach x) + eine Motorstation
(setq _plan
(hw-sym-plan _regeln
(hw-sym-instanzen-sortieren _regeln
(list (list (hw-sym-bauteil-fuer-block _regeln "Vario_Motorstation_rechts")
"Vario_Motorstation_rechts" '(0.0 0.0 0.0) "V1")
(list (hw-sym-bauteil-fuer-block _regeln "Separator_SP_3D")
"Separator_SP_3D" '(500.0 0.0 0.0) "S2")
(list (hw-sym-bauteil-fuer-block _regeln "Separator_SP_3D")
"Separator_SP_3D" '(100.0 0.0 2000.0) "S1")))))
(tu-eq "hw-sym-plan/keys" '("S1" "S1" "S2" "S2" "V1") (mapcar '(lambda (r) (nth 5 r)) _plan))
;; Zielpunkt = Bauteilpunkt (flach), beide Symbole eines Bauteils gleich -
;; die Lage daneben/uebereinander rechnet hw_symbole.py
(tu-eq "hw-sym-plan/erster" '("MB" "MB1@@@" "SYM_MB" (100.0 0.0 0.0) "sep_ventil" "S1" "separator")
(car _plan))
(tu-eq "hw-sym-plan/sensor-gleiches-ziel" '(100.0 0.0 0.0) (nth 3 (cadr _plan)))
(tu-eq "hw-sym-plan/stapelfolge" '("sep_ventil" "sep_sensor")
(list (nth 4 (car _plan)) (nth 4 (cadr _plan))))
(tu-eq "hw-sym-plan/motor-rechts" "SYM_MA_RE" (nth 2 (last _plan)))
;; JSON fuer hw_symbole.py: wieder einlesbar, Komma-Setzung korrekt
(setq _json (ssg-json-parse (ssg-join (hw-sym-plan->json '("MB" "MA") _plan) "\n")))
(tu-eq "hw-sym-plan->json/praefixe" '("MB" "MA") (ssg-json-get _json "praefixe"))
(tu-eq "hw-sym-plan->json/anzahl" 5 (length (ssg-json-get _json "symbole")))
(tu-eq "hw-sym-plan->json/x" 100.0 (ssg-json-get (car (ssg-json-get _json "symbole")) "x"))
(tu-eq "hw-sym-plan->json/leer" nil
(ssg-json-get (ssg-json-parse (ssg-join (hw-sym-plan->json '("BX") nil) "\n")) "symbole"))
;; Ergebnis von create_numbers zuordnen (Wert + Einfuegepunkt)
(setq _eintrag '(("old_value" . "MB1@@@") ("new_value" . "MB1001")
("position" ("x" . 400.0) ("y" . 0))))
(tu-true "hw-num-eintrag-p/passt" (hw-num-eintrag-p _eintrag "MB1@@@" '(400.004 0.0 0.0)))
(tu-nil "hw-num-eintrag-p/wert" (hw-num-eintrag-p _eintrag "BG1@@@" '(400.0 0.0 0.0)))
(tu-nil "hw-num-eintrag-p/lage" (hw-num-eintrag-p _eintrag "MB1@@@" '(400.0 350.0 0.0)))
;; Echtes Regelwerk: jede benoetigte Rolle hat eine Rollen-Definition
(setq _echt (if (getenv "DXFM_CFG")
(ssg-json-laden (strcat (getenv "DXFM_CFG") "/io_regeln.json"))))
(if _echt
(progn
(foreach b (ssg-json-get _echt "bauteile")
(foreach alt (ssg-json-get b "benoetigt")
(foreach r alt (if (null (hw-sym-rolle _echt r)) (setq _fehlt (cons r _fehlt))))))
(tu-nil "hw-sym-regelwerk/rollen-vollstaendig" _fehlt)
(tu-eq "hw-sym-regelwerk/io-vorlage" "MB1@@@" (hw-sym-io-vorlage _echt "MB"))
(tu-eq "hw-sym-regelwerk/as-element" "ausschleuser"
(ssg-json-get (hw-sym-bauteil-fuer-block _echt "AS_Element_90_links_3D") "bauteil")))
(tu-record nil "hw-sym-regelwerk" "cfg/io_regeln.json nicht gefunden (DXFM_CFG?)"))
)
)
)