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
+229 -2
View File
@@ -36,6 +36,31 @@ mehrere Anlagen in einer Datei stehen koennen):
Offene Punkte aendern die TRO-Struktur ausdruecklich **nicht**.
Handkorrekturen aus BricsCAD (seit 2026-10-26)
-----------------------------------------------
Die Abschnitte oben wendet tro_flow nicht mehr an (siehe unten). Die Arten
`edit` und `edit_add` dagegen schon: sie schreibt `tro_annotate --read-tros`
aus den im Dialog TRO_EDIT/TRO_INSERT (dxfmakros) geaenderten Markern, und
`tro_flow.apply_edits` wendet sie nach der Ableitung an. Sie haengen an der
ID aus `<csv>_tro_ids.ini`, die ueber die Lage des Separators stabil bleibt -
anders als die laufenden Nummern, an denen die alten Abschnitte veraltet sind.
[<csv>.edit.<ID>] Korrektur an einem abgeleiteten TRO
type = 1Sep1Swi neuer Katalogtyp (fehlt = wie abgeleitet)
name = Einschleus_1 neue Kennung (fehlt = <ID> bleibt)
delete = true TRO entfaellt
separator1 = 0010 @ 1234.5 -567.8 Bauteil je Slot: TeileId @ x y (mm,
switch1 = - CSV); "-" = Slot bewusst leer
lines = 0066 @ 958 12300, 0069 @ 958 12600 Lagerlinien in Reihenfolge
reason = ... Begruendung (bleibt beim Neuschreiben erhalten)
[<csv>.edit_add.<ID>] neuer TRO (TRO_INSERT), Schluessel wie oben
Stehen Slots im Abschnitt, gelten sie vollstaendig (fehlende Slots des Typs
bleiben leer); stehen keine, bleiben die Bauteile wie abgeleitet. Die Lage
hinter der TeileId macht den Eintrag exportfest: passt die TeileId im neuen
Export nicht mehr zur Lage, gilt das naechste Bauteil derselben Art dort.
Die Felder heissen wie in lib/model.py (`type_name`, `separators`, `hosts`,
`reason`, `confidence`); Verbindungen und offene Punkte sind direkt
`model.Connection` bzw. `model.TroCandidate`.
@@ -45,7 +70,9 @@ from __future__ import annotations
import configparser
import os
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from model import Connection, TroCandidate
@@ -122,9 +149,84 @@ class MergeSpec:
confidence: str = DEFAULT_CONFIDENCE
@dataclass
class PartRef:
"""Bauteil in einem Slot: TeileId plus Lage (mm, CSV) als Rueckfallebene.
Die TeileId wechselt zwischen zwei Exporten derselben Anlage, die Lage
nicht (wie bei der ID-Datei, lib/tro_ids.py). ``teile_id == ""`` ist ein
bewusst leerer Slot.
"""
teile_id: str = ""
x: float | None = None
y: float | None = None
def render(self) -> str:
"""'0010 @ 1234.5 -567.8', '0010' ohne Lage, '-' fuer leer."""
if not self.teile_id:
return "-"
if self.x is None or self.y is None:
return self.teile_id
return f"{self.teile_id} @ {self.x:.1f} {self.y:.1f}"
@classmethod
def parse(cls, raw: str) -> PartRef:
"""Gegenstueck zu :meth:`render` (ValueError bei falscher Form)."""
text = raw.strip()
if text in ("", "-"):
return cls()
teile_id, at, pos = (t.strip() for t in text.partition("@"))
if not teile_id or " " in teile_id:
raise ValueError(f"'{raw}' ist kein Bauteil '<TeileId> @ x y'")
if not at:
return cls(teile_id=teile_id)
werte = pos.replace(",", " ").split()
if len(werte) != 2:
raise ValueError(f"'{raw}': Lage hinter @ braucht x und y")
return cls(teile_id=teile_id, x=float(werte[0]), y=float(werte[1]))
# Slot-Schluessel in einem edit-Abschnitt (lines steht fuer alle Lagerlinien)
_SLOT_KEY = re.compile(r"^(separator|switch|scanner)\d+$|^drive$")
_EDIT_KEYS = ("type", "name", "delete", "reason", "lines")
@dataclass
class TroEdit:
"""Handkorrektur aus BricsCAD (Abschnitt edit bzw. edit_add).
``key`` ist die ID aus ``<csv>_tro_ids.ini`` (edit) bzw. die Kennung des
neuen TRO (edit_add). ``slots``/``lines`` sind None, wenn die Bauteile wie
abgeleitet bleiben.
"""
key: str
add: bool = False
delete: bool = False
name: str = ""
type_name: str = ""
slots: dict[str, PartRef] | None = None
lines: list[PartRef] | None = None
reason: str = ""
@property
def section_kind(self) -> str:
return "edit_add" if self.add else "edit"
def is_empty(self) -> bool:
"""Nichts zu tun (ein edit-Abschnitt ohne Aenderung entfaellt)."""
return not (self.add or self.delete or self.name or self.type_name
or self.slots is not None or self.lines is not None)
@dataclass
class Overrides:
"""Alle Korrekturen zu einer CSV-Datei."""
"""Alle Korrekturen zu einer CSV-Datei.
``edits`` (Handkorrekturen aus BricsCAD) zaehlen fuer ``bool()`` nicht mit:
material_flow --connect liest nur die uebrigen Arten.
"""
source: Path | None = None
tros: dict[str, TroOverride] = field(default_factory=dict)
@@ -133,6 +235,8 @@ class Overrides:
# Gesetzte Topologie: ersetzt die abgeleiteten Kanten vollstaendig
connections: list[Connection] = field(default_factory=list)
candidates: list[TroCandidate] = field(default_factory=list)
# Handkorrekturen aus BricsCAD in Dateireihenfolge (tro_flow.apply_edits)
edits: list[TroEdit] = field(default_factory=list)
# Protokoll der tatsaechlich angewandten Korrekturen (fuer die Doku)
applied: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@@ -294,11 +398,134 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
note=values.get("note", "").strip(),
)
)
elif kind in ("edit", "edit_add"):
edit = _parse_edit(section, name, kind == "edit_add", values,
result.warnings)
if edit is not None:
result.edits.append(edit)
else:
result.warnings.append(
f"[{section}]: unbekannte Art '{kind}' - erwartet 'tro', 'split', "
f"'merge' oder 'openpoint'"
f"'merge', 'openpoint', 'edit' oder 'edit_add'"
)
result.candidates.sort(key=lambda p: p.name)
return result
def _parse_edit(section: str, name: str, add: bool, values,
warnings: list[str]) -> TroEdit | None:
"""Einen edit-/edit_add-Abschnitt lesen (None bei einem Fehler)."""
type_name = values.get("type", "").strip()
if type_name and get_tro(type_name) is None:
warnings.append(f"[{section}]: Typ '{type_name}' steht nicht im TRO-Katalog "
f"- Korrektur ignoriert")
return None
if add and not type_name:
warnings.append(f"[{section}]: neuer TRO ohne 'type' - nicht angelegt")
return None
edit = TroEdit(
key=name,
add=add,
delete=values.get("delete", "").strip().lower() in ("true", "ja", "1", "yes"),
name=values.get("name", "").strip(),
type_name=type_name,
reason=values.get("reason", "").strip(),
)
try:
slots = {key: PartRef.parse(raw) for key, raw in values.items()
if key not in _EDIT_KEYS}
if "lines" in values:
edit.lines = [PartRef.parse(t) for t in values["lines"].split(",") if t.strip()]
except ValueError as exc:
warnings.append(f"[{section}]: {exc} - Korrektur ignoriert")
return None
falsch = sorted(k for k in slots if not _SLOT_KEY.match(k))
if falsch:
warnings.append(f"[{section}]: unbekannte Schluessel {', '.join(falsch)} - "
f"erwartet type, name, delete, reason, lines oder Slots "
f"(separator1, switch1, scanner1, drive)")
return None
if slots:
edit.slots = slots
return edit
# Kopfzeile des von --read-tros geschriebenen Blocks; beim Neuschreiben
# wird sie samt aller edit-/edit_add-Abschnitte der CSV ersetzt.
_EDIT_HEAD = "; --- Handkorrekturen aus BricsCAD zu {csv}"
_HEADER = re.compile(r"^\s*\[(.+)\]\s*$")
def render_edit(csv_name: str, edit: TroEdit) -> str:
"""Einen Abschnitt als INI-Text."""
lines = [f"[{csv_name}.{edit.section_kind}.{edit.key}]"]
if edit.delete:
lines.append("delete = true")
if edit.type_name:
lines.append(f"type = {edit.type_name}")
if edit.name:
lines.append(f"name = {edit.name}")
for slot, ref in (edit.slots or {}).items():
lines.append(f"{slot} = {ref.render()}")
if edit.lines is not None:
lines.append(f"lines = {', '.join(r.render() for r in edit.lines)}")
if edit.reason:
lines.append(f"reason = {edit.reason}")
return "\n".join(lines)
def save_edits(csv_name: str, edits: list[TroEdit], cfg_dir: Path | None = None) -> Path:
"""Alle edit-/edit_add-Abschnitte zu `csv_name` durch `edits` ersetzen.
Der Rest der Datei (von Hand gepflegt, mit Kommentaren) bleibt Zeile fuer
Zeile erhalten; der neue Block steht am Ende. Kommentarzeilen direkt vor
dem naechsten Abschnitt gehoeren zu diesem und bleiben stehen.
"""
path = override_path(cfg_dir)
text = path.read_text(encoding="utf-8") if path.is_file() else ""
prefix = csv_name.lower()
head = _EDIT_HEAD.format(csv=csv_name).lower()
def ist_edit(section: str) -> bool:
parts = section.split(".")
return (len(parts) >= 3 and parts[-2].lower() in ("edit", "edit_add")
and ".".join(parts[:-2]).lower() == prefix)
def ist_rand(line: str) -> bool:
return not line.strip() or line.lstrip().startswith(";")
keep: list[str] = []
skipped: list[str] = []
skipping = False
for line in text.splitlines():
if line.strip().lower().startswith(head):
continue
if m := _HEADER.match(line):
if skipping:
# Kommentare/Leerzeilen am Ende gehoeren zum naechsten Abschnitt
tail: list[str] = []
while skipped and ist_rand(skipped[-1]):
tail.insert(0, skipped.pop())
keep.extend(tail)
skipping = ist_edit(m.group(1).strip())
skipped = []
if skipping:
continue
if skipping:
skipped.append(line)
else:
keep.append(line)
while keep and not keep[-1].strip():
keep.pop()
if edits:
if keep:
keep += ["", ""]
keep.append(_EDIT_HEAD.format(csv=csv_name)
+ f" - tro_annotate --read-tros, {datetime.now():%Y-%m-%d}")
for edit in edits:
keep += [render_edit(csv_name, edit), ""]
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(keep).rstrip("\n") + "\n", encoding="utf-8")
return path