Files
sps_skel/lib/tro_annotate.py
T
s.ayadi 6c8c235287 tro_annotate --read-tros: TRO-Handkorrekturen aus BricsCAD, Staubereiche vor dem TRO
- Marker tragen KEY (ID der ID-Datei) und SLOTS (Bauteil je Slot), die
  Zeichnung die Bauteiltabelle SPS_SKEL_PARTS (XRECORD) fuer "Im Plan
  waehlen" im Dialog TRO_EDIT (dxfmakros)
- --read-tros schreibt geaenderte, neue und geloeschte Marker als
  Abschnitte edit/edit_add nach cfg/tro_overrides.ini; tro_flow.apply_edits
  wendet nur diese Arten an (Bauteile mit Lage, exportfest)
- --emit-lisp schreibt dazu *TRO-SLOTS* und *TRO-SLOT-KINDS*
- model: Lagerlinien zaehlen je TRO (Katalog nur Vorgabe), Mubea-Speicher
  jetzt 20 Linien (Golden-Datei)
- Staubereich als stehendes graues Rechteck direkt vor seinem TRO-Marker
  (jam_abstand statt jam_versatz); Marker 150 mm, Text 120 mm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:35:04 +01:00

1725 lines
73 KiB
Python

# -*- coding: utf-8 -*-
# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3"
# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist.
"""
tro_annotate.py - TRO-Informationen in die BricsCAD-Zeichnung einblenden.
Nimmt die Originalzeichnung (DXF), den CSV-Export und die daraus ermittelten
TROs und schreibt eine **Kopie** der Zeichnung mit zusaetzlicher Beschriftung:
* je TRO ein farbiger Marker als Block mit Attributen
* TRO-Kennung, TRO-Typ, optional der Siemens-Funktionsbaustein
* optional Pfeile fuer den gerichteten Materialfluss zwischen den TROs
* optional eine Legende
Die Zeichnung ist eine **Ansicht** zur Sichtkontrolle, keine Datenquelle: die
TRO-JSON fuer scl_skeleton.py schreibt tro_flow.py --json direkt aus dem CSV.
tro_annotate leitet die TROs mit genau demselben Aufruf ab wie tro_flow, damit
Zeichnung und JSON uebereinstimmen. (tro_extract.py, der Rueckweg Zeichnung ->
JSON, ist geparkt.)
Die Originalzeichnung wird nie veraendert. Alle Ergaenzungen liegen auf eigenen
Layern (Vorgabe: Praefix "TRO_") und tragen XDATA der Anwendungskennung
SPS_SKEL_TRO. Ein erneuter Lauf entfernt genau diese Objekte und baut sie neu -
gelaeuscht wird nach XDATA, nicht nach Layername, damit eigene Objekte auf
denselben Layern unangetastet bleiben.
Marker als Block mit Attributen
-------------------------------
Je TRO-Typ wird eine Blockdefinition angelegt (TRO_SYM_<Typ>) und an der
Anlagenkoordinate eingefuegt. **Jeder Typ hat seine eigene Form** (Kreis,
Dreieck, Rechteck, Raute ...; siehe TroSymbol in tro_catalog), sodass sich die
Typen auch ohne Farbe unterscheiden. Die Daten stehen als Attribute am Block:
ID TRO03 sichtbar, im Dialog bearbeitbar
TYPE 1Sep sichtbar, im Dialog bearbeitbar
FB_BLOCK FB_ILS_... sichtbar nur mit --fb
UID 0062 unsichtbar, dauerhafte interne Nummer
KEY TRO03 unsichtbar, Schluessel in tro_overrides.ini (ID der
ID-Datei; leer bei einem mit TRO_INSERT gesetzten)
ITEMS 1x Separator unsichtbar, reine Daten
CONFIDENCE mittel unsichtbar
SLOTS separator1=0010;switch1=0061 unsichtbar, Bauteil je Slot
(TeileId; Lagerlinien als lines=0066,0069,...),
im Dialog TRO_EDIT bearbeitbar
Handkorrekturen (--read-tros)
-----------------------------
Im Dialog TRO_EDIT (dxfmakros) aendern die SPS-Programmierer Typ, Kennung und
die Bauteile je Slot; TRO_INSERT setzt neue Marker, geloeschte Marker fallen
weg. Fuer "Im Plan waehlen" legt die Beschriftung die Bauteile des Exports
(TeileId, Art, Lage in der Zeichnung) als XRECORD SPS_SKEL_PARTS in das
Named Object Dictionary. --read-tros vergleicht die Marker mit der Ableitung
und schreibt die Abweichungen als Abschnitte edit/edit_add nach
cfg/tro_overrides.ini; tro_flow und tro_annotate wenden sie beim naechsten
Lauf an (tro_flow.apply_edits).
Zwei Kennungen, mit Absicht:
ID die sprechende Kennung (TRO03). Darf sich aendern - der Dialog TRO_EDIT
und TRO_INSERT in dxfmakros arbeiten damit.
UID die eindeutige, dauerhafte interne Nummer. Vierstellig wie die TeileId
des CSV-Exports und wie das ID-Attribut von SSG_LIB
(dxfmakros/Lisp/ssg_id.lsp), damit sie im selben Nummernraum liegt und
nicht mit bestehenden Nummern kollidiert; vergeben wird oberhalb der
hoechsten schon belegten Nummer aus Zeichnung *und* CSV.
Bewusst ein eigenes Tag und nicht ID: in dieser Zeichnung steht in ID
schon die sprechende Kennung, und IDSCHECK wuerde sie beim Pruefen auf
Dubletten ueberschreiben.
Verbindungen
------------
Mit --flow wird je Kante zusaetzlich ein Block CONNECTION_ARROW in die Mitte des
Pfeils gesetzt. Er haelt die Beziehung ueber die **UIDs** fest (FROM_UID/TO_UID,
dazu FROM_TRO/TO_TRO als Klartext), sodass sie im DXF-Export eindeutig lesbar
bleibt, auch wenn ein Marker verschoben oder seine sprechende Kennung geaendert
wird.
Damit sind die Daten in BricsCAD auswertbar und aenderbar. Zum Bearbeiten gibt es
die Dialoge in dxfmakros: TRO_EDIT (dcl/tro_edit.dcl) und CONNECTION_EDIT
(dcl/connection_edit.dcl); die Liste der gueltigen Typen dafuer schreibt
--emit-lisp aus dem Katalog nach DXFM_LISP.
Lagebezug
---------
CSV und Zeichnung stehen in verschiedenen Koordinatensystemen. Der Versatz wird
von lib/dxf_registration.py ermittelt, nachgewiesen und in SKEL_CFG abgelegt;
spaetere Laeufe verwenden den abgelegten Wert. --re-register erzwingt eine neue
Ermittlung, --offset setzt ihn von Hand.
Ausgabe (in SKEL_RESULTS)
-------------------------
<name>_annotated.dxf Kopie der Zeichnung mit Beschriftung
<name>_registration.json verwendeter Lagebezug samt Nachweis
Aufruf ueber bin/tro_annotate.bat bzw. bin/tro_annotate.sh.
Benoetigt ezdxf (siehe requirements.txt). DWG wird nicht unterstuetzt - die
Zeichnung muss als DXF vorliegen.
"""
from __future__ import annotations
import argparse
import collections
import configparser
import csv as csv_module
import fnmatch
import json
import math
import os
import sys
from pathlib import Path
import ezdxf
from ezdxf.enums import TextEntityAlignment
from dxf_registration import (
ANCHOR_BLOCKS,
RegistrationError,
Transform,
derive_from_ids,
derive_transform,
load_transform,
save_transform,
verify_transform,
)
from material_flow import build_graph, env_dir, read_elements, resolve_input
from tro_catalog import STYLES, TRO_CATALOG, TroSymbol, get_tro
from model import Analysis
from parameter import get_float
from tro_flow import (
BOUNDARY_ENTRY,
BOUNDARY_EXIT,
STATION_FARBE,
_station_positionen,
analyse_tros,
find_sibling_raw_json,
load_sepliste_counts,
resolve_ids_file,
)
from tro_ids import TroIds
from model import (
ANLAGE_SCHEMA,
SLOT_PART_KINDS,
AnlageDocument,
DerivedTro,
JamArea,
derive_slots,
has_lines,
slot_kind,
slot_names,
)
from tro_ids import id_problem
from tro_overrides import PartRef, TroEdit, load_overrides, override_path, save_edits
from scl_context import (
JAM_NAMES_SUFFIX,
HwSource,
flow_order,
jam_key,
jam_label,
jam_numbers,
load_jam_overrides,
save_jam_overrides,
)
# ---------------------------------------------------------------------------
# Konstanten
# ---------------------------------------------------------------------------
APPID = "SPS_SKEL_TRO"
LAYER_PREFIX = "TRO_"
BLOCK_PREFIX = "TRO_SYM_"
# Verbindungsblock. Der Name beginnt mit CONNECTION_, damit der Dispatcher
# SSG_BLOCKEDIT in dxfmakros ihn ueber "CONNECTION_*" erkennt.
CONNECTION_BLOCK = "CONNECTION_ARROW"
# Staubereich (--anlage). Der Name beginnt mit JAM_, damit SSG_BLOCKEDIT ihn
# ueber "JAM_*" an den Dialog JAM_EDIT (dxfmakros/Lisp/Jam_Edit.lsp) gibt.
JAM_BLOCK = "JAM_AREA"
# Bauteile des Exports fuer "Im Plan waehlen" im Dialog TRO_EDIT: XRECORD im
# Named Object Dictionary, je Bauteil (1 . TeileId) (3 . Art) (10 . Lage)
# (40 . halbe Breite) (41 . halbe Hoehe) in Zeichnungskoordinaten.
PARTS_XRECORD = "SPS_SKEL_PARTS"
# Unsichtbare Attribute der Marker (build_symbol)
HIDDEN_TAGS = ("UID", "KEY", "ITEMS", "CONFIDENCE", "SLOTS")
# Anzeige eines Staubereichs ohne Foerderer (Nummer nicht ableitbar)
JAM_OFFEN = "Jam????_?"
# Zeichnungsgroessen und Restfehler-Grenze des Lagebezugs aus cfg/parameter.ini,
# Abschnitt [tro_annotate] (lib/parameter.py)
DEFAULT_MARKER = get_float("tro_annotate", "default_marker")
DEFAULT_TEXT = get_float("tro_annotate", "default_text")
DEFAULT_MAX_RESIDUAL = get_float("tro_annotate", "default_max_residual")
JAM_ABSTAND = get_float("tro_annotate", "jam_abstand")
# Staubereich als stehendes Rechteck, Masse relativ zur Texthoehe: halbe
# Breite / halbe Hoehe, Name senkrecht darin (Jam0054_3 passt hinein).
JAM_HALB_BREITE = 0.5
JAM_HALB_HOEHE = 2.6
JAM_TEXT = 0.6
# Fuellung und Rand (ACI 8 = grau) und Schrift (ACI 250 = fast schwarz)
JAM_FUELLUNG = "#d0d0d0"
def hex_to_rgb(value: str) -> tuple[int, int, int]:
"""'#e8d4f0' -> (232, 212, 240)"""
text = value.lstrip("#")
return (int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))
def lisp_dir() -> Path:
"""
Zielordner fuer die erzeugte LISP-Typliste.
Bevorzugt DXFM_LISP - dort liegen die SSG_LIB-Module in dxfmakros, die den
Dialog TRO_EDIT bereitstellen. Ohne die Variable landet die Datei in
<SPS_SKEL>/cad, damit der Lauf auch ohne dxfmakros funktioniert.
"""
env = os.environ.get("DXFM_LISP")
if env:
return Path(env)
return Path(__file__).resolve().parent.parent / "cad"
def emit_lisp(cad_dir: Path) -> Path:
"""
Typliste fuer den DCL-Dialog schreiben.
Der Dialog TRO_EDIT (dxfmakros/Lisp/Tro_Edit.lsp) bietet TYPE als
Auswahlliste an. Damit sie nicht von der Typdefinition abweicht, wird sie
hier aus TRO_CATALOG erzeugt statt in der LISP-Datei gepflegt.
"""
cad_dir.mkdir(parents=True, exist_ok=True)
path = cad_dir / "tro_types.lsp"
types = " ".join(f'"{d.get_name()}"' for d in TRO_CATALOG)
def lisp_list(values) -> str:
return " ".join(f'"{v}"' for v in values)
# Je Typ die Slots in der Reihenfolge von model.derive_slots, dazu die
# Vorgabe der Lagerlinien (0 = keine): ("PinStore_Auto" 19 "separator1" ...)
slots = "\n".join(
" (" + " ".join([f'"{d.get_name()}"', str(has_lines(d.get_name()))]
+ [f'"{s}"' for s in slot_names(d.get_name())]) + ")"
for d in TRO_CATALOG)
kinds = "\n".join(f' ("{k}" {lisp_list(v)})' for k, v in SLOT_PART_KINDS.items())
path.write_text(
";; Automatisch erzeugt von lib/tro_annotate.py --emit-lisp\n"
";; Quelle: TRO_CATALOG in lib/tro_catalog.py, Slots aus lib/model.py\n"
";; (slot_names, SLOT_PART_KINDS) - nicht manuell aendern.\n"
f"(setq *TRO-TYPES* (list {types}))\n"
";; (Typ Lagerlinien-Vorgabe Slot ...) - Lagerlinien heissen line1..lineN\n"
f"(setq *TRO-SLOTS* '(\n{slots}\n))\n"
";; (Slot-Art Bauteilart ...) - Pruefung beim Waehlen im Plan\n"
f"(setq *TRO-SLOT-KINDS* '(\n{kinds}\n))\n"
"(princ)\n",
encoding="utf-8",
)
return path
# ---------------------------------------------------------------------------
# Lagebezug
# ---------------------------------------------------------------------------
def anchor_points_csv(elements) -> dict[str, list[tuple[float, float]]]:
"""Punktartige CSV-Objekte je Rolle (Separator, Scanner)."""
points: dict[str, list[tuple[float, float]]] = collections.defaultdict(list)
for element in elements:
if element.kind in ANCHOR_BLOCKS and element.position:
points[element.kind].append((element.x, element.y))
return dict(points)
def anchor_points_dxf(msp) -> dict[str, list[tuple[float, float]]]:
"""Einfuegepunkte der Anker-Bloecke je Rolle.
ANCHOR_BLOCKS enthaelt Namensmuster, keine festen Namen - die von den
Makros erzeugten Zeichnungen haengen an jeden Blocknamen ein `_2D`/`_3D`
an (Separator_SP_3D statt S-SP).
"""
points: dict[str, list[tuple[float, float]]] = collections.defaultdict(list)
for ref in msp.query("INSERT"):
name = ref.dxf.name.upper()
for role, patterns in ANCHOR_BLOCKS.items():
if any(fnmatch.fnmatchcase(name, p.upper()) for p in patterns):
points[role].append((ref.dxf.insert.x, ref.dxf.insert.y))
break
return dict(points)
def _role_of(block_name: str) -> str:
"""Rolle eines Blocks nach ANCHOR_BLOCKS ("" wenn keine)."""
name = block_name.upper()
for role, patterns in ANCHOR_BLOCKS.items():
if any(fnmatch.fnmatchcase(name, p.upper()) for p in patterns):
return role
return ""
def id_points_csv(elements) -> dict[str, tuple[str, float, float]]:
"""TeileId -> (Rolle, x, y) der punktartigen CSV-Objekte."""
return {e.teile_id: (e.kind, e.x, e.y) for e in elements
if e.kind in ANCHOR_BLOCKS and e.position and e.teile_id}
def id_points_dxf(msp) -> dict[str, tuple[str, float, float]]:
"""ID-Attribut -> (Rolle, x, y) der Anker-Bloecke im Modellbereich.
Nur Bloecke einer Rolle aus ANCHOR_BLOCKS mit einer Ziffern-ID; eine ID,
die zweimal vorkommt, ist nicht eindeutig und faellt weg.
"""
found: dict[str, tuple[str, float, float]] = {}
doppelt: set[str] = set()
for ref in msp.query("INSERT"):
role = _role_of(ref.dxf.name)
if not role:
continue
uid = next(((a.dxf.text or "").strip() for a in ref.attribs
if a.dxf.tag.upper() == "ID"), "")
if not uid.isdigit():
continue
if uid in found:
doppelt.add(uid)
found[uid] = (role, ref.dxf.insert.x, ref.dxf.insert.y)
return {k: v for k, v in found.items() if k not in doppelt}
def resolve_transform(
args, elements, msp, cfg_dir: Path, csv_name: str, dxf_name: str,
warnings: list[str],
) -> Transform:
"""Lagebezug bestimmen: Kommandozeile, TeileId, Konfiguration oder Ermittlung.
Die TeileId (derive_from_ids) geht vor dem abgelegten und dem ermittelten
Bezug: sie ordnet eindeutig zu und gilt auch fuer eine verschobene Kopie
der Zeichnung. Sie wird jedes Mal neu gerechnet, nicht abgelegt.
"""
csv_points = anchor_points_csv(elements)
dxf_points = anchor_points_dxf(msp)
if not args.offset:
by_ids = derive_from_ids(id_points_csv(elements), id_points_dxf(msp),
csv_name, dxf_name)
if by_ids is not None:
verify_transform(by_ids, csv_points, dxf_points)
return by_ids
if args.offset:
try:
dx, dy = (float(v) for v in args.offset.split(","))
except ValueError:
raise RegistrationError(
f"--offset '{args.offset}' nicht lesbar, erwartet 'dx,dy'"
) from None
transform = Transform(dx=dx, dy=dy, source="cli", csv_file=csv_name,
dxf_file=dxf_name, anchor="von Hand gesetzt")
verify_transform(transform, csv_points, dxf_points)
return transform
if not args.re_register:
stored = load_transform(cfg_dir, csv_name, dxf_name)
if stored:
# Auch der abgelegte Bezug wird gegen die Zeichnung nachgerechnet
verify_transform(stored, csv_points, dxf_points)
return stored
transform = derive_transform(csv_points, dxf_points, csv_name, dxf_name)
saved = save_transform(cfg_dir, transform)
warnings.append(f"Lagebezug neu ermittelt und abgelegt: {saved}")
return transform
# ---------------------------------------------------------------------------
# Zeichnung vorbereiten
# ---------------------------------------------------------------------------
def ensure_appid(doc) -> None:
if APPID not in doc.appids:
doc.appids.add(APPID)
def remove_previous(msp) -> int:
"""Objekte frueherer Laeufe entfernen - erkannt an ihren XDATA."""
doomed = []
for entity in msp:
try:
if entity.get_xdata(APPID):
doomed.append(entity)
except (ezdxf.DXFValueError, ezdxf.DXFTypeError):
continue
for entity in doomed:
msp.delete_entity(entity)
return len(doomed)
def tag(entity, tro_id: str = "") -> None:
"""Objekt als von diesem Programm erzeugt kennzeichnen."""
entity.set_xdata(APPID, [(1000, "tro_annotate"), (1000, tro_id or "-")])
def format_internal_id(number: int) -> str:
"""Vierstellige interne Nummer, wie (ssg-id-format n) in dxfmakros."""
return f"{number:04d}"
def next_internal_id(msp, elements=None) -> int:
"""
Naechste freie interne ID.
SSG_LIB vergibt jedem eingefuegten Baustein im Attribut ID eine eindeutige
vierstellige Nummer (Lisp/ssg_id.lsp); dieselbe Nummer erscheint im
CSV-Export als TeileId. Beides ist **derselbe** ID-Raum.
Deshalb wird das Maximum aus zwei Quellen gebildet:
* ID-Attribute der Bloecke in der Zeichnung
* TeileId aller Objekte des CSV-Exports
Die zweite Quelle ist der wichtigere Fall: eine mechanische Zeichnung, die
nicht mit SSG_LIB gebaut wurde, hat gar keine ID-Attribute (im Mubea-Plan
tragen nur 10 von 669 Bloecken ueberhaupt Attribute). Ohne die TeileIds
wuerde die Beschriftung bei 0001 anfangen und damit Nummern belegen, die im
CSV schon Bauteile bezeichnen - IDSCHECK saehe spaeter Dubletten.
"""
highest = 0
for ref in msp.query("INSERT"):
try:
attribs = ref.attribs
except AttributeError:
continue
for attrib in attribs:
if attrib.dxf.tag.upper() != "ID":
continue
text = (attrib.dxf.text or "").strip()
if text.isdigit():
highest = max(highest, int(text))
for element in elements or ():
teile_id = (element.teile_id or "").strip()
if teile_id.isdigit():
highest = max(highest, int(teile_id))
return highest + 1
def build_layers(doc, prefix: str) -> dict[str, str]:
"""Layer je Farbgruppe plus Label-, Fluss- und Legendenlayer."""
names: dict[str, str] = {}
for style in STYLES:
name = f"{prefix}MARKER_{style.group}"
names[style.group] = name
if name not in doc.layers:
# Strichfarbe, nicht Fuellfarbe: im CAD ist das Symbol eine Linie.
# Die pastelligen Fuellfarben waeren als Linien kaum zu sehen.
doc.layers.add(name, dxfattribs={
"true_color": ezdxf.rgb2int(hex_to_rgb(style.stroke)),
})
# 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]),
("jam", "#808080"),
("legend", "#808080")):
name = f"{prefix}{key.upper()}"
names[key] = name
if name not in doc.layers:
doc.layers.add(name, dxfattribs={
"true_color": ezdxf.rgb2int(hex_to_rgb(colour)),
})
return names
def _polygon(sides: int, radius: float, start_deg: float) -> list[tuple[float, float]]:
"""Eckpunkte eines regelmaessigen Vielecks um den Ursprung."""
return [
(radius * math.cos(math.radians(start_deg + i * 360 / sides)),
radius * math.sin(math.radians(start_deg + i * 360 / sides)))
for i in range(sides)
]
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.
Farbe 256 = BYLAYER, damit die Farbe des INSERT-Layers (Farbgruppe des Typs)
durchschlaegt. Alle Formen liegen um den Ursprung, damit der Einfuegepunkt
des Blocks die Anlagenkoordinate ist.
"""
attr = {"color": 256}
if symbol == TroSymbol.CIRCLE:
blk.add_circle((0, 0), r, dxfattribs=attr)
elif symbol == TroSymbol.DOUBLE_CIRCLE:
blk.add_circle((0, 0), r, dxfattribs=attr)
blk.add_circle((0, 0), r * 0.6, dxfattribs=attr)
elif symbol == TroSymbol.TRIANGLE:
blk.add_lwpolyline(_polygon(3, r, 90), close=True, dxfattribs=attr)
elif symbol == TroSymbol.TRIANGLE_DOWN:
blk.add_lwpolyline(_polygon(3, r, 270), close=True, dxfattribs=attr)
elif symbol == TroSymbol.DOUBLE_TRIANGLE_DOWN:
blk.add_lwpolyline(_polygon(3, r, 270), close=True, dxfattribs=attr)
blk.add_lwpolyline(_polygon(3, r * 0.55, 270), close=True, dxfattribs=attr)
elif symbol == TroSymbol.SQUARE:
blk.add_lwpolyline(_polygon(4, r, 45), close=True, dxfattribs=attr)
elif symbol == TroSymbol.DIAMOND:
blk.add_lwpolyline(_polygon(4, r, 90), close=True, dxfattribs=attr)
elif symbol == TroSymbol.PENTAGON:
blk.add_lwpolyline(_polygon(5, r, 90), close=True, dxfattribs=attr)
elif symbol == TroSymbol.HEXAGON:
blk.add_lwpolyline(_polygon(6, r, 0), close=True, dxfattribs=attr)
elif symbol == TroSymbol.RECT:
w, h = r * 1.45, r * 0.72
blk.add_lwpolyline([(-w, -h), (w, -h), (w, h), (-w, h)],
close=True, dxfattribs=attr)
elif symbol == TroSymbol.ARROW:
blk.add_lwpolyline(
[(-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)],
close=True, dxfattribs=attr)
else:
# unbekanntes Symbol: Kreis mit Kreuz, faellt auf
blk.add_circle((0, 0), r, dxfattribs=attr)
blk.add_line((-r, -r), (r, r), dxfattribs=attr)
blk.add_line((-r, r), (r, -r), dxfattribs=attr)
def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool) -> str:
"""
Blockdefinition fuer einen TRO-Typ anlegen: Symbol + Attributdefinitionen.
Je Typ eine eigene Form (siehe TroSymbol), damit sich die Typen in der
Zeichnung auch ohne Farbe unterscheiden.
Attribute (Tags nach der Konvention von dxfmakros/Lisp/ssg_id.lsp):
ID unsichtbar - die eindeutige, dauerhafte interne Nummer im
ID-Raum von SSG_LIB (vierstellig, wie die TeileId im CSV).
IDSCHECK prueft und korrigiert sie zeichnungsweit.
TRO_ID sichtbar - der sprechende Name (TRO01), vom Dialog aenderbar
TYPE sichtbar - TRO-Typ, vom Dialog aenderbar
"""
name = f"{BLOCK_PREFIX}{type_name}"
if name in doc.blocks:
# Blockdefinition aus einem frueheren Lauf (bleibt, weil von Hand
# gesetzte Marker sie benutzen): fehlende Datenattribute nachtragen,
# sonst haetten die neuen Marker z. B. kein KEY/SLOTS.
blk = doc.blocks.get(name)
vorhanden = {a.dxf.tag.upper() for a in blk.query("ATTDEF")}
offset = min((a.dxf.insert.y for a in blk.query("ATTDEF")), default=0.0)
for tag_name in HIDDEN_TAGS:
if tag_name not in vorhanden:
offset -= text
blk.add_attdef(tag_name, insert=(marker * 1.5, offset), height=text * 0.7,
dxfattribs={"style": "Standard", "color": 256, "text": "",
"invisible": 1})
return name
blk = doc.blocks.new(name=name)
definition = get_tro(type_name)
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)]
if show_fb:
rows.append(("FB_BLOCK", "FB", text * 0.85))
offset = 0.0
for tag_name, default, height in rows:
blk.add_attdef(
tag_name,
insert=(gap, offset),
height=height,
dxfattribs={"style": "Standard", "color": 256, "text": default},
)
offset -= height * 1.55
# nicht sichtbare Attribute: reine Daten; KEY und SLOTS liest der Dialog
# TRO_EDIT. ID zuerst - gleiche Reihenfolge wie *strecke-attr-front* in dxfmakros.
for tag_name in HIDDEN_TAGS:
blk.add_attdef(
tag_name,
insert=(gap, offset),
height=text * 0.7,
dxfattribs={"style": "Standard", "color": 256, "text": "",
"invisible": 1},
)
offset -= text
return name
# ---------------------------------------------------------------------------
# Beschriftung zeichnen
# ---------------------------------------------------------------------------
def place_markers(
msp, doc, analysis: Analysis, transform: Transform, layers: dict[str, str],
marker: float, text: float, show_fb: bool, warnings: list[str],
next_id: int = 1,
) -> tuple[dict[str, tuple[float, float]], dict[str, str]]:
"""
Je TRO einen Blockmarker mit Attributen einfuegen.
Rueckgabe: (Position je TRO, interne ID je TRO). Die interne ID wird ab
`next_id` fortlaufend vergeben, damit sie mit den bereits in der Zeichnung
vorhandenen IDs nicht kollidiert.
"""
placed: dict[str, tuple[float, float]] = {}
internal: dict[str, str] = {}
for tro in analysis.tros:
if not tro.position:
warnings.append(f"{tro.tro_id}: keine Koordinate - nicht beschriftet")
continue
x, y = transform.to_dxf(tro.position[0], tro.position[1])
placed[tro.tro_id] = (x, y)
internal[tro.tro_id] = format_internal_id(next_id)
next_id += 1
definition = get_tro(tro.type_name)
group = definition.get_group() if definition else "ext"
block = build_symbol(doc, tro.type_name, marker, text, show_fb)
values = {
"ID": tro.tro_id,
"UID": internal[tro.tro_id],
"KEY": analysis.edit_key(tro.tro_id),
"TYPE": tro.type_name,
"ITEMS": ", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())),
"CONFIDENCE": tro.confidence,
"SLOTS": format_slots(*tro_slots(tro)),
}
if show_fb:
values["FB_BLOCK"] = tro.fb_block or "-"
ref = msp.add_blockref(block, (x, y), dxfattribs={
"layer": layers.get(group, layers["ext"]),
})
ref.add_auto_attribs(values)
tag(ref, tro.tro_id)
return placed, internal
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.
Der Block sitzt in der Mitte des Pfeils und traegt die Beziehung als
Attribute; Schaft und Spitze sind eigene Geometrie auf dem Fluss-Layer.
Grund fuer diese Trennung: ein Block mit fester Geometrie kann nicht zwei
beliebige Punkte verbinden - ihn in X zu skalieren wuerde die Pfeilspitze
verzerren. Der Mittelpunkt-Marker ist anklickbar (Doppelklick -> Dialog)
und bleibt beim Zoomen gleich gross.
Attribute:
UID unsichtbar - eindeutige interne Nummer, dauerhaft
FROM_UID unsichtbar - interne UID des Quell-TRO
TO_UID unsichtbar - interne UID des Ziel-TRO
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:
return name
blk = doc.blocks.new(name=name)
# Kleiner Doppelpfeil-Winkel als Griff, entlang +X ausgerichtet
size = marker * 0.55
blk.add_lwpolyline([(-size, size * 0.7), (size, 0), (-size, -size * 0.7)],
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": "",
"invisible": 1},
)
offset -= text * 1.2
for tag_name in ("UID", "FROM_UID", "TO_UID", "KIND"):
blk.add_attdef(
tag_name,
insert=(size * 1.3, offset),
height=text * 0.7,
dxfattribs={"style": "Standard", "color": 256, "text": "",
"invisible": 1},
)
offset -= text
return name
def draw_connections(
msp, doc, analysis: Analysis, placed: dict[str, tuple[float, float]],
internal: dict[str, str], layers: dict[str, str], marker: float, text: float,
next_id: int = 1,
) -> int:
"""
Verbindungen zwischen den TROs zeichnen.
Je Verbindung: Schaft und Pfeilspitze als Geometrie plus ein
CONNECTION_ARROW-Block in der Mitte, der Quelle und Ziel ueber die
**internen IDs** festhaelt. Damit bleibt die Beziehung im DXF-Export
lesbar, auch wenn ein Marker verschoben oder umbenannt wird.
"""
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 < 1e-6:
continue
ux, uy = (x2 - x1) / length, (y2 - y1) / length
# 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)
# Zu kurz fuer einen gekuerzten Pfeil (Marker liegen dicht beieinander,
# z.B. HundM05 TRO03->TRO04 mit 500 mm): NICHT weglassen - sonst fehlt
# die Verbindung in tro_extract.py und damit in der SCL. Stattdessen
# Mitte-zu-Mitte ohne Kuerzung zeichnen.
kurz = length <= kurz_a + kurz_b + head
if kurz:
kurz_a = kurz_b = 0.0
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": layer})
tag(shaft, f"{src}->{dst}")
# Pfeilspitze als gefuellte Flaeche (bei kurzer Verbindung in der Mitte,
# damit sie nicht im Zielmarker verschwindet)
hx, hy = ((x1 + x2) / 2 + ux * head / 2, (y1 + y2) / 2 + uy * head / 2) \
if kurz else (ex, ey)
bx, by = hx - ux * head, hy - uy * head
px, py = -uy * head * 0.38, ux * head * 0.38
point = msp.add_solid(
[(hx, hy), (bx + px, by + py), (bx - px, by - py)],
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(
block, ((sx + ex) / 2, (sy + ey) / 2),
dxfattribs={"layer": layers["flow"],
"rotation": math.degrees(math.atan2(uy, ux))},
)
ref.add_auto_attribs({
"UID": format_internal_id(next_id),
"FROM_UID": internal.get(src, ""),
"TO_UID": internal.get(dst, ""),
"FROM_TRO": src,
"TO_TRO": dst,
"KIND": kind,
})
tag(ref, f"{src}->{dst}")
next_id += 1
drawn += 1
return drawn
def build_jam_symbol(doc, text: float) -> str:
"""
Blockdefinition fuer einen Staubereich: stehendes graues Rechteck, der
Name senkrecht mittig darin.
Attribute:
NAME sichtbar - Jam0054_3 (Foerderer + laufende Nummer)
CONVEYOR unsichtbar - Foerderer NNNN (Kreisel-/Vario-Motor), im Dialog
NUMBER unsichtbar - laufende Nummer k auf dem Foerderer, im Dialog
TRO unsichtbar - TRO, in den der Staubereich fuehrt (TRO17)
ENTRY unsichtbar - Eingang des TRO (1, bei 2Sep-Typen auch 2)
AREA unsichtbar - Kennung der TRO-JSON (J0039)
AUTO unsichtbar - abgeleitete Nummer NNNN_k; --read-jams schreibt
nur, was davon abweicht
TRO + ENTRY ist der stabile Schluessel (scl_context.jam_key).
"""
if JAM_BLOCK in doc.blocks:
# Form aus einem frueheren Lauf (liegendes Rechteck) ersetzen; die
# Attributdefinitionen bleiben, damit vorhandene Bloecke gueltig sind.
blk = doc.blocks.get(JAM_BLOCK)
for e in list(blk):
if e.dxftype() != "ATTDEF":
blk.delete_entity(e)
_jam_form(blk, text)
for a in blk.query("ATTDEF"):
if a.dxf.tag.upper() == "NAME":
_jam_name(a, text)
return JAM_BLOCK
blk = doc.blocks.new(name=JAM_BLOCK)
_jam_form(blk, text)
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
name = blk.add_attdef("NAME", insert=(0, 0), height=text * JAM_TEXT,
dxfattribs={"style": "Standard", "text": JAM_OFFEN})
_jam_name(name, text)
offset = -h - text
for tag_name in ("CONVEYOR", "NUMBER", "TRO", "ENTRY", "AREA", "AUTO"):
blk.add_attdef(tag_name, insert=(-w, offset), height=text * 0.7,
dxfattribs={"style": "Standard", "color": 256, "text": "",
"invisible": 1})
offset -= text
return JAM_BLOCK
def _jam_form(blk, text: float) -> None:
"""Stehendes Rechteck: graue Fuellung, grauer Rand."""
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
ecken = [(-w, -h), (w, -h), (w, h), (-w, h)]
hatch = blk.add_hatch(dxfattribs={"true_color": ezdxf.rgb2int(hex_to_rgb(JAM_FUELLUNG))})
hatch.paths.add_polyline_path(ecken, is_closed=True)
blk.add_lwpolyline(ecken, close=True, dxfattribs={"color": 8})
def _jam_name(attdef, text: float) -> None:
"""Name senkrecht (90 Grad) mittig ins Rechteck."""
attdef.dxf.height = text * JAM_TEXT
attdef.dxf.rotation = 90.0
attdef.dxf.color = 250
attdef.set_placement((0, 0), align=TextEntityAlignment.MIDDLE_CENTER)
def _jam_position(area: JamArea, anlage: AnlageDocument,
marker: float, text: float) -> tuple[float, float] | None:
"""Lage eines Staubereichs (CSV-Koordinaten): direkt vor seinem TRO.
Ausgangspunkt ist der TRO-Marker (die TRO-Position, nicht der Separator:
bei Speichern und 2Sep-Typen liegt der weit vom Marker). Das Rechteck
sitzt gerade so weit davon, dass es den Marker nicht beruehrt
(Markerradius + JAM_ABSTAND + halbe Breite), ein zweiter Eingang direkt
daneben - in Richtung des ersten Vorgaenger-TRO, gegen den Fluss, wo die
Traeger warten. Ohne Vorgaenger mit Lage (Station) gegen die Richtung zum
Nachfolger. Liegt der Nachbar naeher, hoechstens die halbe Strecke. Nur
Lage - Verbindungen und Fliessrichtung bleiben unberuehrt.
"""
layout = anlage.layout
tro = layout.by_id(area.tro)
if tro is not None and tro.position:
x, y = tro.position[0], tro.position[1]
elif area.position is not None:
x, y = area.position[0], area.position[1]
else:
return None
breite = 2 * text * JAM_HALB_BREITE
abstand = marker + JAM_ABSTAND + breite / 2 + (area.entry - 1) * (breite + JAM_ABSTAND)
def richtung(ids: list[str], vorzeichen: float):
for other_id in ids:
other = layout.by_id(other_id)
if other is None or not other.position:
continue
vx, vy = other.position[0] - x, other.position[1] - y
dist = math.hypot(vx, vy)
if dist > 1e-6:
schritt = min(abstand, dist / 2)
return (x + vorzeichen * vx / dist * schritt,
y + vorzeichen * vy / dist * schritt)
return None
lage = richtung(area.predecessors or (tro.predecessors if tro else []), 1.0)
if lage is None and tro is not None:
lage = richtung(tro.successors, -1.0)
return lage if lage is not None else (x - abstand, y)
def place_jams(
msp, doc, anlage: AnlageDocument, overrides: dict[str, tuple[str, int]],
transform: Transform, layers: dict[str, str], text: float, warnings: list[str],
marker: float = DEFAULT_MARKER,
) -> int:
"""
Je Staubereich der Anlagen-JSON einen JAM_AREA-Block einfuegen.
Die Nummern sind dieselben wie in der SCL (scl_context.jam_numbers, dann
die Korrekturen aus <csv>_jam_names.ini). Ein Staubereich ohne Foerderer
zeigt Jam????_? - im Dialog nachtragen.
"""
layout = anlage.layout
geordnet, _grund = flow_order(layout.tros)
automatisch = jam_numbers(layout, geordnet, HwSource(anlage=anlage))
block = build_jam_symbol(doc, text)
gezeichnet = 0
for area in layout.jam_areas_or_derived():
lage = _jam_position(area, anlage, marker, text)
if lage is None:
warnings.append(f"Staubereich {area.jam_id} ({area.tro}): keine Lage - "
f"nicht gezeichnet")
continue
auto = automatisch.get(area.jam_id)
nr, k = overrides.get(jam_key(area), auto or ("", 0))
ref = msp.add_blockref(block, transform.to_dxf(*lage),
dxfattribs={"layer": layers["jam"]})
ref.add_auto_attribs({
"NAME": jam_label(nr, k) if nr else JAM_OFFEN,
"CONVEYOR": nr,
"NUMBER": str(k) if nr else "",
"TRO": area.tro,
"ENTRY": str(area.entry),
"AREA": area.jam_id,
"AUTO": f"{auto[0]}_{auto[1]}" if auto else "",
})
tag(ref, area.tro)
gezeichnet += 1
return gezeichnet
def read_jams(dxf_file: Path, ini_file: Path) -> tuple[dict[str, tuple[str, int]], list[str]]:
"""Im Dialog JAM_EDIT geaenderte Staubereichs-Nummern uebernehmen.
Liest die JAM_AREA-Bloecke der beschrifteten Zeichnung. Weicht
CONVEYOR_NUMBER von AUTO ab, kommt der Staubereich in die Korrekturdatei
``<csv>_jam_names.ini``; stimmt er (wieder) mit AUTO ueberein, faellt sein
Eintrag heraus. Eintraege zu Staubereichen, die nicht in der Zeichnung
stehen, bleiben. Rueckgabe: (Korrekturen danach, Meldungen).
"""
meldungen: list[str] = []
overrides = load_jam_overrides(ini_file)
doc = ezdxf.readfile(str(dxf_file))
namen: dict[str, str] = {}
for ref in doc.modelspace().query(f'INSERT[name=="{JAM_BLOCK}"]'):
a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs}
key = f"{a.get('TRO', '')}.{a.get('ENTRY', '')}"
nr, k = a.get("CONVEYOR", ""), a.get("NUMBER", "")
if not a.get("TRO") or not a.get("ENTRY"):
meldungen.append(f"Staubereich ohne TRO/ENTRY ({a.get('NAME', '?')}) - uebersprungen")
continue
if not nr and not k:
continue
if not (nr.isdigit() and len(nr) == 4 and k.isdigit() and int(k) > 0):
meldungen.append(f"{key} ({a.get('AREA', '')}): Foerderer '{nr}' / Nummer '{k}' "
f"ungueltig (erwartet NNNN und k >= 1) - uebersprungen")
continue
wert = f"{nr}_{int(k)}"
if wert in namen.values():
andere = next(s for s, w in namen.items() if w == wert)
meldungen.append(f"{key}: Jam{wert} ist schon {andere} - doppelt vergeben")
namen[key] = wert
alt = overrides.get(key)
if wert == a.get("AUTO", ""):
if alt is not None:
del overrides[key]
meldungen.append(f"{key}: wieder abgeleitet (Jam{wert}) - Korrektur entfernt")
elif alt != (nr, int(k)):
overrides[key] = (nr, int(k))
meldungen.append(f"{key}: Jam{wert} (abgeleitet: Jam{a.get('AUTO') or '-'})")
save_jam_overrides(ini_file, overrides)
return overrides, meldungen
# ---------------------------------------------------------------------------
# Handkorrekturen an den TROs (Dialog TRO_EDIT, --read-tros)
# ---------------------------------------------------------------------------
def tro_slots(tro: DerivedTro) -> tuple[dict[str, str], list[str]]:
"""Bauteile je Slot (ohne Lagerlinien) und die Lagerlinien in Reihenfolge."""
slots: dict[str, str] = {}
lines: list[str] = []
for slot in derive_slots(tro):
if slot_kind(slot.slot) == "line":
lines.append(slot.layout_id)
else:
slots[slot.slot] = slot.layout_id
return slots, lines
def format_slots(slots: dict[str, str], lines: list[str]) -> str:
"""Attribut SLOTS: 'separator1=0010;switch1=;lines=0066,0069'."""
text = ";".join(f"{k}={v}" for k, v in slots.items())
if lines:
text += (";" if text else "") + "lines=" + ",".join(lines)
return text
def parse_slots(text: str) -> tuple[dict[str, str], list[str] | None]:
"""Gegenstueck zu format_slots; lines ist None, wenn das Attribut keine nennt."""
slots: dict[str, str] = {}
lines: list[str] | None = None
for part in text.split(";"):
key, sep, value = (t.strip() for t in part.partition("="))
if not sep or not key:
continue
if key.lower() == "lines":
lines = [v.strip() for v in value.split(",") if v.strip()]
else:
slots[key.lower()] = value
return slots, lines
def write_parts(doc, elements, transform: Transform) -> int:
"""Bauteile, die in einen Slot passen, als XRECORD in die Zeichnung legen."""
kinds = {k for v in SLOT_PART_KINDS.values() for k in v}
tags: list[tuple[int, object]] = []
for e in elements:
if e.kind not in kinds or e.position is None or not e.teile_id:
continue
x, y = transform.to_dxf(e.x, e.y)
w, h = (e.bbox[0] / 2, e.bbox[1] / 2) if e.bbox else (0.0, 0.0)
tags += [(1, e.teile_id), (3, e.kind), (10, (x, y, 0.0)), (40, w), (41, h)]
root = doc.rootdict
if PARTS_XRECORD in root:
root.remove(PARTS_XRECORD)
xrecord = root.add_xrecord(PARTS_XRECORD)
xrecord.reset(tags)
return len(tags) // 5
def read_markers(dxf_file: Path) -> list[dict[str, str]]:
"""Attribute aller TRO-Marker (Bloecke TRO_SYM_*) der Zeichnung."""
doc = ezdxf.readfile(str(dxf_file))
markers = []
for ref in doc.modelspace().query("INSERT"):
if not ref.dxf.name.upper().startswith(BLOCK_PREFIX):
continue
a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs}
a["_BLOCK"] = ref.dxf.name
markers.append(a)
return markers
def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
csv_name: str, cfg_dir: Path | None = None,
write: bool = True) -> tuple[list[TroEdit], list[str]]:
"""Marker der Zeichnung mit der Ableitung vergleichen -> Handkorrekturen.
``raw`` ist die Ableitung OHNE Handkorrekturen (IDs aus der ID-Datei). Je
Marker mit KEY eines abgeleiteten TRO entsteht ein edit-Abschnitt, wenn
Typ, Kennung oder Bauteile abweichen; ein Marker ohne passenden KEY ist
ein neuer TRO (edit_add); ein abgeleiteter TRO mit Lage, zu dem kein
Marker mehr da ist, wurde geloescht. Die Abschnitte ersetzen alle
bisherigen edit-/edit_add-Abschnitte der CSV in tro_overrides.ini
(Begruendungen bleiben). Rueckgabe: (Korrekturen, Meldungen).
"""
meldungen: list[str] = []
by_id = {e.teile_id: e for e in elements}
alt = {(e.section_kind, e.key): e
for e in load_overrides(csv_name, cfg_dir).edits}
def ref(teile_id: str) -> PartRef:
if not teile_id:
return PartRef()
element = by_id.get(teile_id.split("-", 1)[0])
if element is None or element.position is None:
return PartRef(teile_id=teile_id)
return PartRef(teile_id=teile_id, x=element.x, y=element.y)
def pruefen(where: str, type_name: str, slots: dict[str, str],
lines: list[str]) -> None:
for slot in slot_names(type_name):
teile_id = slots.get(slot, "")
if not teile_id:
if slot.startswith("separator"):
meldungen.append(f"{where}: {slot} ist leer")
continue
element = by_id.get(teile_id.split("-", 1)[0])
kinds = SLOT_PART_KINDS[slot_kind(slot)]
if element is None:
meldungen.append(f"{where}: {slot} = {teile_id} steht nicht im Export")
elif element.kind not in kinds:
meldungen.append(f"{where}: {slot} = {teile_id} ist ein {element.kind}, "
f"erwartet {'/'.join(kinds)}")
if has_lines(type_name) and not lines:
meldungen.append(f"{where}: Speicher ohne Lagerlinie")
raw_by_id = {t.tro_id: t for t in raw.tros}
edits: list[TroEdit] = []
gesehen: set[str] = set()
vergeben: dict[str, str] = {}
ids: dict[str, str] = {}
for m in markers:
tro_id, key = m.get("ID", ""), m.get("KEY", "")
type_name = m.get("TYPE", "") or m["_BLOCK"][len(BLOCK_PREFIX):]
where = f"Marker {tro_id or '?'}"
if not tro_id:
meldungen.append(f"{where} ({m['_BLOCK']}): ohne ID - uebersprungen")
continue
if get_tro(type_name) is None:
meldungen.append(f"{where}: Typ '{type_name}' steht nicht im Katalog - "
f"uebersprungen")
continue
if problem := id_problem(tro_id):
meldungen.append(f"{where}: {problem} - uebersprungen")
continue
if tro_id in ids:
meldungen.append(f"{where}: Kennung doppelt (auch {ids[tro_id]})")
ids[tro_id] = key or tro_id
slots, lines = parse_slots(m.get("SLOTS", ""))
if key in gesehen:
# kopierter Marker: gleicher KEY wie ein schon gelesener
meldungen.append(f"{where}: KEY {key} doppelt (Marker kopiert?) - als "
f"neuer TRO gelesen")
key = ""
derived = raw_by_id.get(key)
if derived is not None:
gesehen.add(key)
raw_slots, raw_lines = tro_slots(derived)
else:
raw_slots, raw_lines = {}, []
if lines is None:
lines = raw_lines if has_lines(type_name) else []
pruefen(where, type_name, slots, lines)
for teile_id in [v for v in slots.values() if v] + lines:
if teile_id in vergeben and vergeben[teile_id] != tro_id:
meldungen.append(f"{where}: Bauteil {teile_id} steckt auch in "
f"{vergeben[teile_id]}")
vergeben[teile_id] = tro_id
names = slot_names(type_name)
if derived is None:
edit = TroEdit(key=tro_id, add=True, type_name=type_name,
slots={s: ref(slots.get(s, "")) for s in names},
lines=[ref(t) for t in lines] if has_lines(type_name) else None)
else:
edit = TroEdit(key=key)
if type_name != derived.type_name:
edit.type_name = type_name
if tro_id != key:
edit.name = tro_id
if any(slots.get(s, "") != raw_slots.get(s, "") for s in names):
edit.slots = {s: ref(slots.get(s, "")) for s in names}
if has_lines(type_name) and lines != raw_lines:
edit.lines = [ref(t) for t in lines]
if edit.is_empty():
continue
if (vorher := alt.get((edit.section_kind, edit.key))) is not None:
edit.reason = vorher.reason
edits.append(edit)
meldungen.append(_describe_edit(edit))
for tro in raw.tros:
if tro.tro_id in gesehen or not tro.position:
continue
edit = TroEdit(key=tro.tro_id, delete=True)
if (vorher := alt.get(("edit", tro.tro_id))) is not None:
edit.reason = vorher.reason
edits.append(edit)
meldungen.append(_describe_edit(edit))
if write:
save_edits(csv_name, edits, cfg_dir)
return edits, meldungen
def _describe_edit(edit: TroEdit) -> str:
if edit.delete:
return f"{edit.key}: geloescht"
teile = []
if edit.add:
teile.append(f"neu ({edit.type_name})")
elif edit.type_name:
teile.append(f"Typ -> {edit.type_name}")
if edit.name:
teile.append(f"Kennung -> {edit.name}")
if edit.slots is not None and not edit.add:
teile.append("Bauteile " + ", ".join(
f"{k}={r.teile_id or '-'}" for k, r in edit.slots.items()))
if edit.lines is not None:
teile.append(f"{len(edit.lines)} Lagerlinien")
return f"{edit.key}: {'; '.join(teile)}"
def draw_legend(
msp, analysis: Analysis, placed: dict[str, tuple[float, float]],
layers: dict[str, str], marker: float, text: float, source: str,
) -> None:
"""Legende links unterhalb der beschrifteten Objekte."""
if not placed:
return
x0 = min(p[0] for p in placed.values()) - marker * 12
y0 = min(p[1] for p in placed.values()) - marker * 6
row = text * 2.2
title = msp.add_text(
f"TRO-Beschriftung - {source}",
height=text * 1.5,
dxfattribs={"layer": layers["legend"], "style": "Standard"},
)
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}
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)")]
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)
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
# ---------------------------------------------------------------------------
# Bericht
# ---------------------------------------------------------------------------
def report(
analysis: Analysis, transform: Transform, csv_file: Path, dxf_file: Path,
out_file: Path | None, reg_file: Path | None, counts: dict, warnings: list[str],
) -> None:
print("")
print("================================================================")
print("TRO-BESCHRIFTUNG")
print("================================================================")
print(f"CSV = {csv_file}")
print(f"Zeichnung = {dxf_file}")
print(f"Lagebezug = {transform.describe()}")
print(f" Herkunft = {transform.source}"
+ (f", ermittelt {transform.created}" if transform.created else ""))
print(f" Nachweis = {transform.anchor}")
print(f" Restfehler = max {transform.residual_max:.1f} mm, "
f"mittel {transform.residual_mean:.1f} mm "
f"({transform.matched}/{transform.candidates} Punkte)")
print(f"TROs = {len(analysis.tros)}, beschriftet: {counts['marker']}"
+ (f" (Handkorrekturen: {len(analysis.renamed)} umbenannt, "
f"{len(analysis.added)} neu)" if analysis.renamed or analysis.added else ""))
if counts.get("parts"):
print(f"Bauteile = {counts['parts']} (XRECORD {PARTS_XRECORD}, fuer "
f"TRO_EDIT 'Im Plan waehlen'; uebernehmen mit --read-tros)")
print(f"Verbindungen = {counts['flow']}"
+ (" (Block CONNECTION_ARROW mit FROM_UID/TO_UID)" if counts["flow"] else ""))
if counts.get("jams"):
print(f"Staubereiche = {counts['jams']} (Block {JAM_BLOCK}, Doppelklick -> "
f"JAM_EDIT; uebernehmen mit --read-jams)")
if counts.get("first_id"):
print(f"Interne IDs ab = {format_internal_id(counts['first_id'])} "
f"(ID-Raum von SSG_LIB, siehe IDSCHECK)")
print(f"Entfernte Objekte= {counts['removed']} (aus frueherem Lauf)")
print(f"Layer = {counts['layers']}, Bloecke: {counts['blocks']}")
print(f"DXF = {out_file if out_file else '- (--check)'}")
print(f"Lagebezug-Datei = {reg_file if reg_file else '-'}")
print("================================================================")
if warnings:
print("")
print(f"Hinweise ({len(warnings)}):")
for warning in warnings:
print(f" ! {warning}")
print("")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="tro_annotate",
description="Blendet die ermittelten TROs in eine Kopie der "
"BricsCAD-Zeichnung ein (DXF).",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Beispiele:\n"
" tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --check\n"
" tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --flow --fb\n"
" tro_annotate.bat --file dematic_merge.csv --dxf dematic_merge..dxf --flow "
"--anlage dematic_merge_anlage.json\n"
" tro_annotate.bat --file dematic_merge.csv --read-jams\n"
" tro_annotate.bat --file mubea.csv --read-tros --check\n"
"\n"
"Exit-Codes:\n"
" 0 ok\n"
" 1 Eingabe- oder Aufruffehler\n"
" 4 Lagebezug nicht nachweisbar\n",
)
parser.add_argument("--file", default="export.csv", metavar="NAME",
help="CSV-Export in %%SKEL_DATA%% oder ein Pfad. "
"Standard: %(default)s")
parser.add_argument("--dxf", metavar="NAME",
help="Originalzeichnung als DXF (Pfad oder Name in "
"%%SKEL_DATA%%). Ausser bei --emit-lisp erforderlich.")
parser.add_argument("--out", metavar="NAME",
help="Name der Ausgabedatei in %%SKEL_RESULTS%%. "
"Standard: <csv>_annotated.dxf")
parser.add_argument("--flow", action="store_true",
help="Pfeile fuer den Materialfluss zwischen den TROs "
"zeichnen.")
parser.add_argument("--fb", action="store_true",
help="Den Siemens-Funktionsbaustein als sichtbares "
"Attribut am Marker anzeigen.")
parser.add_argument("--legend", action="store_true",
help="Legende der Farbgruppen einzeichnen.")
parser.add_argument("--marker-size", type=float, default=DEFAULT_MARKER,
metavar="MM", help="Markerradius in mm. Standard: %(default)s")
parser.add_argument("--text-height", type=float, default=DEFAULT_TEXT,
metavar="MM", help="Texthoehe in mm. Standard: %(default)s")
parser.add_argument("--ids", metavar="DATEI",
help="ID-Datei der TROs wie bei tro_flow (nur gelesen). "
"Standard: <csv>_tro_ids.ini in %%SKEL_CFG%%.")
parser.add_argument("--offset", metavar="DX,DY",
help="Lagebezug von Hand setzen statt ermitteln.")
parser.add_argument("--re-register", action="store_true",
help="Lagebezug neu ermitteln, auch wenn in %%SKEL_CFG%% "
"einer abgelegt ist.")
parser.add_argument("--anlage", metavar="NAME",
help="Anlagen-JSON (<csv>_anlage.json aus merge_hw_layout, in "
"%%SKEL_RESULTS%% oder ein Pfad): zusaetzlich die "
"Staubereiche als JAM_AREA-Bloecke einzeichnen "
"(Jam<NNNN>_<k>, Doppelklick -> Dialog JAM_EDIT).")
parser.add_argument("--read-jams", nargs="?", const="", default=None, metavar="DXF",
help="Im Dialog JAM_EDIT geaenderte Staubereichs-Nummern aus der "
"beschrifteten Zeichnung uebernehmen (Standard: "
"<csv>_annotated.dxf in %%SKEL_RESULTS%%) und nach "
"%%SKEL_CFG%%\\<csv>_jam_names.ini schreiben, dann beenden. "
"scl_skeleton liest die Datei.")
parser.add_argument("--read-tros", nargs="?", const="", default=None, metavar="DXF",
help="Im Dialog TRO_EDIT/TRO_INSERT geaenderte, neue und "
"geloeschte TRO-Marker aus der beschrifteten Zeichnung "
"uebernehmen (Standard: <csv>_annotated.dxf in "
"%%SKEL_RESULTS%%) und als Abschnitte edit/edit_add nach "
"%%SKEL_CFG%%\\tro_overrides.ini schreiben, dann beenden. "
"tro_flow und tro_annotate wenden sie an.")
parser.add_argument("--check", action="store_true",
help="Nur pruefen und berichten, keine Datei schreiben.")
parser.add_argument("--emit-lisp", action="store_true",
help="cad/tro_types.lsp aus dem Typkatalog neu schreiben "
"(Auswahlliste des Dialogs TROEDIT) und beenden.")
return parser.parse_args(argv)
def _main_read_jams(args, csv_file: Path) -> int:
"""--read-jams: Nummern aus der beschrifteten Zeichnung -> <csv>_jam_names.ini."""
results = env_dir("SKEL_RESULTS", "results")
name = args.read_jams or args.out or f"{csv_file.stem}_annotated.dxf"
dxf_file = Path(name)
if not dxf_file.is_absolute() and dxf_file.parent == Path("."):
dxf_file = results / name
if not dxf_file.is_file():
print(f"FEHLER: beschriftete Zeichnung nicht gefunden: {dxf_file}", file=sys.stderr)
print(" In BricsCAD als DXF speichern (Dateiname wie beim Beschriften).",
file=sys.stderr)
return 1
ini_file = env_dir("SKEL_CFG", "cfg") / f"{csv_file.stem}{JAM_NAMES_SUFFIX}"
try:
overrides, meldungen = read_jams(dxf_file, ini_file)
except (OSError, ValueError, configparser.Error, ezdxf.DXFError) as exc:
print(f"FEHLER: {exc}", file=sys.stderr)
return 1
print("")
print("================================================================")
print("STAUBEREICHE UEBERNEHMEN")
print("================================================================")
print(f"Zeichnung = {dxf_file}")
print(f"Korrekturen = {len(overrides)} -> {ini_file if overrides else '- (keine, Datei entfernt)'}")
for m in meldungen:
print(f" {m}")
print("Danach: scl_skeleton mit der Anlagen-JSON neu laufen lassen.")
print("================================================================")
print("")
return 0
def _derive(args, csv_file: Path, edits: list[TroEdit] | None):
"""CSV lesen und die TROs ableiten - (Elemente, Analysis) oder None.
Derselbe Aufruf wie in tro_flow.main (mit der Separator-Gegenprobe aus
<name>_raw.json, der ID-Datei und den Handkorrekturen), damit die
Zeichnung genau die TROs der TRO-JSON zeigt - sie ist nur Ansicht, keine
Datenquelle. --read-tros vergleicht mit der Ableitung ohne Korrekturen
(edits=None).
"""
read_warnings: list[str] = []
try:
elements = read_elements(csv_file, read_warnings)
except (OSError, ValueError, csv_module.Error) as exc:
print(f"FEHLER: {csv_file} nicht lesbar: {exc}", file=sys.stderr)
return None
graph = build_graph(elements, csv_file.name, read_warnings)
raw_json_file = find_sibling_raw_json(csv_file)
sepliste_counts = (load_sepliste_counts(raw_json_file, read_warnings)
if raw_json_file is not None else {})
# Dieselbe ID-Datei wie tro_flow, aber nur lesen: neue TROs bekommen
# dieselbe vorlaeufige ID, die tro_flow anhaengen wuerde.
ids_file = resolve_ids_file(args.ids, csv_file)
try:
tro_ids = TroIds.load(ids_file) if ids_file.is_file() else None
except (OSError, ValueError) as exc:
print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr)
return None
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids, edits=edits)
if not analysis.tros:
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
return None
return elements, analysis
def _main_read_tros(args, csv_file: Path) -> int:
"""--read-tros: Marker der beschrifteten Zeichnung -> tro_overrides.ini."""
results = env_dir("SKEL_RESULTS", "results")
cfg_dir = env_dir("SKEL_CFG", "cfg")
name = args.read_tros or args.out or f"{csv_file.stem}_annotated.dxf"
dxf_file = Path(name)
if not dxf_file.is_absolute() and dxf_file.parent == Path("."):
dxf_file = results / name
if not dxf_file.is_file():
print(f"FEHLER: beschriftete Zeichnung nicht gefunden: {dxf_file}", file=sys.stderr)
print(" In BricsCAD als DXF speichern (Dateiname wie beim Beschriften).",
file=sys.stderr)
return 1
if not csv_file.is_file():
print(f"FEHLER: CSV nicht gefunden: {csv_file}", file=sys.stderr)
return 1
try:
markers = read_markers(dxf_file)
except (OSError, ezdxf.DXFError) as exc:
print(f"FEHLER: {dxf_file} nicht lesbar: {exc}", file=sys.stderr)
return 1
derived = _derive(args, csv_file, None)
if derived is None:
return 1
elements, raw = derived
# Schutz: eine falsche Zeichnung (ohne oder mit fremden Markern) wuerde
# sonst alle TROs als geloescht eintragen.
keys = {m.get("KEY", "") for m in markers}
if not markers or not keys & {t.tro_id for t in raw.tros}:
print(f"FEHLER: {dxf_file.name} enthaelt keine Marker zu den TROs aus "
f"{csv_file.name} ({len(markers)} Marker) - nichts geschrieben. Zeichnung "
f"mit dem aktuellen tro_annotate neu beschriften?", file=sys.stderr)
return 1
try:
edits, meldungen = read_tros(markers, raw, elements, csv_file.name, cfg_dir,
write=not args.check)
except (OSError, configparser.Error) as exc:
print(f"FEHLER: {exc}", file=sys.stderr)
return 1
print("")
print("================================================================")
print("TRO-HANDKORREKTUREN UEBERNEHMEN")
print("================================================================")
print(f"Zeichnung = {dxf_file} ({len(markers)} Marker)")
ziel = override_path(cfg_dir)
print(f"Korrekturen = {len(edits)} -> "
f"{'- (--check, nichts geschrieben)' if args.check else ziel}")
for m in meldungen:
print(f" {m}")
print("Danach: tro_flow --json (und tro_annotate) neu laufen lassen.")
print("================================================================")
print("")
return 0
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
warnings: list[str] = []
if args.emit_lisp:
path = emit_lisp(lisp_dir())
print(f"Typliste geschrieben: {path} ({len(TRO_CATALOG)} Typen)")
if not os.environ.get("DXFM_LISP"):
print("HINWEIS: DXFM_LISP nicht gesetzt - die Datei gehoert neben die "
"SSG_LIB-Module (dxfmakros/Lisp), damit TRO_EDIT sie findet.")
return 0
csv_file = resolve_input(args.file)
if csv_file.suffix.lower() != ".csv":
print(f"FEHLER: --file erwartet eine CSV-Datei, keine '{csv_file.suffix}'-Datei: "
f"{csv_file}", file=sys.stderr)
return 1
if args.read_jams is not None:
return _main_read_jams(args, csv_file)
if args.read_tros is not None:
return _main_read_tros(args, csv_file)
if not args.dxf:
print("FEHLER: --dxf fehlt (nur --emit-lisp, --read-jams und --read-tros gehen "
"ohne Zeichnung).", file=sys.stderr)
return 1
if not csv_file.is_file():
print(f"FEHLER: CSV nicht gefunden: {csv_file}", file=sys.stderr)
return 1
dxf_file = Path(args.dxf)
if not dxf_file.is_absolute() and dxf_file.parent == Path("."):
dxf_file = env_dir("SKEL_DATA", "data") / args.dxf
if not dxf_file.is_file():
print(f"FEHLER: Zeichnung nicht gefunden: {dxf_file}", file=sys.stderr)
return 1
if dxf_file.suffix.lower() == ".dwg":
print("FEHLER: DWG wird nicht unterstuetzt - bitte als DXF exportieren.",
file=sys.stderr)
return 1
results = env_dir("SKEL_RESULTS", "results")
cfg_dir = env_dir("SKEL_CFG", "cfg")
results.mkdir(parents=True, exist_ok=True)
# Anlagen-JSON fuer die Staubereiche (optional)
anlage = None
jam_overrides: dict[str, tuple[str, int]] = {}
if args.anlage:
anlage_file = Path(args.anlage)
if not anlage_file.is_absolute() and anlage_file.parent == Path("."):
anlage_file = results / args.anlage
try:
text = anlage_file.read_text(encoding="utf-8")
if json.loads(text).get("schema") != ANLAGE_SCHEMA:
raise ValueError(f"kein Schema '{ANLAGE_SCHEMA}' - erwartet wird die "
f"Anlagen-JSON aus merge_hw_layout")
anlage = AnlageDocument.from_json(text)
jam_overrides = load_jam_overrides(
cfg_dir / f"{csv_file.stem}{JAM_NAMES_SUFFIX}")
except (OSError, ValueError, configparser.Error) as exc:
print(f"FEHLER: Anlagen-JSON {anlage_file} nicht lesbar: {exc}", file=sys.stderr)
return 1
if Path(anlage.layout.source).name != csv_file.name:
print(f"FEHLER: {anlage_file.name} gehoert zu '{anlage.layout.source}', "
f"nicht zu '{csv_file.name}'.", file=sys.stderr)
return 1
# TROs aus dem CSV, mit den Handkorrekturen aus tro_overrides.ini
overrides = load_overrides(csv_file.name, cfg_dir)
warnings += [w for w in overrides.warnings if ".edit" in w]
derived = _derive(args, csv_file, overrides.edits)
if derived is None:
return 1
elements, analysis = derived
# Zeichnung
try:
doc = ezdxf.readfile(str(dxf_file))
except (OSError, ezdxf.DXFError) as exc:
print(f"FEHLER: {dxf_file} nicht lesbar: {exc}", file=sys.stderr)
return 1
msp = doc.modelspace()
# Lagebezug
try:
transform = resolve_transform(args, elements, msp, cfg_dir,
csv_file.name, dxf_file.name, warnings)
except RegistrationError as exc:
print(f"FEHLER: Lagebezug nicht bestimmbar: {exc}", file=sys.stderr)
return 4
if transform.residual_max > DEFAULT_MAX_RESIDUAL:
print(f"FEHLER: Restfehler des Lagebezugs {transform.residual_max:.1f} mm "
f"ueber der Grenze von {DEFAULT_MAX_RESIDUAL:.1f} mm.", file=sys.stderr)
print(f" {transform.describe()}", file=sys.stderr)
print(" Mit --offset von Hand setzen.", file=sys.stderr)
return 4
counts = {"marker": 0, "flow": 0, "removed": 0, "layers": 0, "blocks": 0,
"first_id": 0, "jams": 0}
if anlage is not None:
aus_csv = {t.tro_id for t in analysis.tros}
aus_json = {t.tro_id for t in anlage.layout.tros}
if aus_csv != aus_json:
warnings.append(
f"TROs der Zeichnung und der Anlagen-JSON weichen ab (nur CSV: "
f"{', '.join(sorted(aus_csv - aus_json)) or '-'}; nur JSON: "
f"{', '.join(sorted(aus_json - aus_csv)) or '-'}) - tro_flow --json und "
f"merge_hw_layout neu laufen lassen")
if args.check:
counts["marker"] = sum(1 for t in analysis.tros if t.position)
report(analysis, transform, csv_file, dxf_file, None, None, counts, warnings)
return 0
ensure_appid(doc)
counts["removed"] = remove_previous(msp)
layers = build_layers(doc, LAYER_PREFIX)
counts["layers"] = len({v for v in layers.values()})
# Interne IDs erst NACH remove_previous suchen: die Marker des letzten Laufs
# sind dann weg und ihre Nummern wieder frei.
next_id = next_internal_id(msp, elements)
placed, internal = place_markers(
msp, doc, analysis, transform, layers, args.marker_size,
args.text_height, args.fb, warnings, next_id=next_id,
)
counts["marker"] = len(placed)
counts["blocks"] = sum(1 for b in doc.blocks if b.name.startswith(BLOCK_PREFIX))
counts["first_id"] = next_id
counts["parts"] = write_parts(doc, elements, transform)
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, **stationen}, internal, layers,
args.marker_size, args.text_height, next_id=next_id + len(placed),
)
if anlage is not None:
counts["jams"] = place_jams(msp, doc, anlage, jam_overrides, transform, layers,
args.text_height, warnings, marker=args.marker_size)
if args.legend:
draw_legend(msp, analysis, placed, layers, args.marker_size,
args.text_height, csv_file.name)
out_file = results / (args.out or f"{csv_file.stem}_annotated.dxf")
try:
doc.saveas(str(out_file))
except PermissionError:
print(f"FEHLER: {out_file} laesst sich nicht schreiben - ist die Zeichnung noch "
f"in BricsCAD geoeffnet? Schliessen und neu starten, oder --out <name>.",
file=sys.stderr)
return 1
reg_file = results / f"{csv_file.stem}_registration.json"
reg_file.write_text(
json.dumps(transform.model_dump(), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
report(analysis, transform, csv_file, dxf_file, out_file, reg_file,
counts, warnings)
return 0
if __name__ == "__main__":
sys.exit(main())