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>
This commit is contained in:
2026-10-26 14:35:04 +01:00
parent 9ec1022fb1
commit 6c8c235287
12 changed files with 1441 additions and 125 deletions
+476 -66
View File
@@ -35,9 +35,24 @@ Typen auch ohne Farbe unterscheiden. Die Daten stehen als Attribute am Block:
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
SEPARATORS 0010 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
@@ -122,7 +137,19 @@ from tro_flow import (
resolve_ids_file,
)
from tro_ids import TroIds
from model import ANLAGE_SCHEMA, AnlageDocument, JamArea
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,
@@ -147,6 +174,12 @@ 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????_?"
@@ -155,7 +188,14 @@ JAM_OFFEN = "Jam????_?"
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_VERSATZ = get_float("tro_annotate", "jam_versatz")
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]:
@@ -189,10 +229,26 @@ def emit_lisp(cad_dir: Path) -> Path:
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 - nicht manuell aendern.\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",
)
@@ -403,7 +459,7 @@ def build_layers(doc, prefix: str) -> dict[str, str]:
("weiche", "#cc8800"),
("eingang", STATION_FARBE[BOUNDARY_ENTRY]),
("ausgang", STATION_FARBE[BOUNDARY_EXIT]),
("jam", "#7030a0"),
("jam", "#808080"),
("legend", "#808080")):
name = f"{prefix}{key.upper()}"
names[key] = name
@@ -518,6 +574,18 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool)
"""
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)
@@ -542,9 +610,9 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool)
)
offset -= height * 1.55
# nicht sichtbare Attribute: reine Daten, nicht im Dialog.
# ID zuerst - gleiche Reihenfolge wie *strecke-attr-front* in dxfmakros.
for tag_name in ("UID", "ITEMS", "CONFIDENCE", "SEPARATORS"):
# 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),
@@ -591,10 +659,11 @@ def place_markers(
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,
"SEPARATORS": ", ".join(tro.separators),
"SLOTS": format_slots(*tro_slots(tro)),
}
if show_fb:
values["FB_BLOCK"] = tro.fb_block or "-"
@@ -773,7 +842,8 @@ def draw_connections(
def build_jam_symbol(doc, text: float) -> str:
"""
Blockdefinition fuer einen Staubereich: Rechteck, der Name mittig darin.
Blockdefinition fuer einen Staubereich: stehendes graues Rechteck, der
Name senkrecht mittig darin.
Attribute:
NAME sichtbar - Jam0054_3 (Foerderer + laufende Nummer)
@@ -787,15 +857,23 @@ def build_jam_symbol(doc, text: float) -> str:
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)
w, h = text * 4.2, text * 1.2
blk.add_lwpolyline([(-w, -h), (w, -h), (w, h), (-w, h)], close=True,
dxfattribs={"color": 256})
name = blk.add_attdef("NAME", insert=(0, 0), height=text,
dxfattribs={"style": "Standard", "color": 256,
"text": JAM_OFFEN})
name.set_placement((0, 0), align=TextEntityAlignment.MIDDLE_CENTER)
_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,
@@ -805,36 +883,70 @@ def build_jam_symbol(doc, text: float) -> str:
return JAM_BLOCK
def _jam_position(area: JamArea, anlage: AnlageDocument) -> tuple[float, float] | None:
"""Lage eines Staubereichs (CSV-Koordinaten): vor seinem Separator.
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})
Ausgangspunkt ist der Separator (sonst die TRO-Position); von dort
JAM_VERSATZ in Richtung des ersten Vorgaenger-TRO - gegen den Fluss, wo
die Traeger warten. Liegt der Vorgaenger naeher, nur die halbe Strecke.
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
part = layout.part(area.separator) if area.separator else None
if part is not None and part.position is not None:
x, y = part.position[0], part.position[1]
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
for vor in area.predecessors:
tro = layout.by_id(vor)
if tro is None or not tro.position:
continue
vx, vy = tro.position[0] - x, tro.position[1] - y
dist = math.hypot(vx, vy)
if dist > 1e-6:
schritt = min(JAM_VERSATZ, dist / 2)
return x + vx / dist * schritt, y + vy / dist * schritt
return x, y
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.
@@ -849,7 +961,7 @@ def place_jams(
block = build_jam_symbol(doc, text)
gezeichnet = 0
for area in layout.jam_areas_or_derived():
lage = _jam_position(area, anlage)
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")
@@ -915,6 +1027,218 @@ def read_jams(dxf_file: Path, ini_file: Path) -> tuple[dict[str, tuple[str, int]
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,
@@ -1000,7 +1324,12 @@ def report(
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']}")
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"):
@@ -1039,6 +1368,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
" 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"
@@ -1085,6 +1415,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"<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",
@@ -1125,6 +1462,96 @@ def _main_read_jams(args, csv_file: Path) -> int:
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] = []
@@ -1145,10 +1572,12 @@ def main(argv: list[str] | None = None) -> int:
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 und --read-jams gehen ohne "
"Zeichnung).", file=sys.stderr)
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)
@@ -1192,33 +1621,13 @@ def main(argv: list[str] | None = None) -> int:
f"nicht zu '{csv_file.name}'.", file=sys.stderr)
return 1
# TROs aus dem CSV
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 1
graph = build_graph(elements, csv_file.name, read_warnings)
# Derselbe Aufruf wie in tro_flow.main (ohne cfg/tro_overrides.ini, mit
# der Separator-Gegenprobe aus <name>_raw.json und der ID-Datei), damit die Zeichnung genau
# die TROs der TRO-JSON zeigt - sie ist nur Ansicht, keine Datenquelle.
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 1
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids)
if not analysis.tros:
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
# 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:
@@ -1274,6 +1683,7 @@ def main(argv: list[str] | None = None) -> int:
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:
@@ -1285,7 +1695,7 @@ def main(argv: list[str] | None = None) -> int:
)
if anlage is not None:
counts["jams"] = place_jams(msp, doc, anlage, jam_overrides, transform, layers,
args.text_height, warnings)
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)