Add TRO annotation tool writing an annotated copy of the drawing
Takes the original DXF, the CSV export and the derived TROs and writes a copy of the drawing with the TRO information overlaid. The original is never touched. Per TRO a block marker with attributes, not loose geometry: one block definition per type (TRO_SYM_<type>), inserted at the plant coordinate, carrying TRO_ID, TRO_TYPE, FB_BLOCK visibly and ITEMS, CONFIDENCE, SEPARATORS as hidden data. That makes the result queryable and editable inside BricsCAD, and a corrected TRO_ID can later feed back into the generator. Layers follow the colour groups of tro_catalog (TRO_MARKER_sep, _vario, _store, ...) with the group colour as true colour and entities BYLAYER, so the layer manager filters by TRO type. Flow arrows and the legend get their own layers. Re-runs are idempotent through XDATA, not layer names: every generated entity is tagged with the SPS_SKEL_TRO appid and a re-run deletes exactly those. Deleting by layer would also destroy anything a user had put on the same layer. Switches follow the other tools; --check reports the registration and what would be drawn without writing, --flow adds the TRO-level arrows, --fb shows the function block, --offset and --re-register override the stored transform. DWG is rejected with a clear message since ezdxf cannot read it. Verified against the real 23.8 MB drawing: 9 markers on the correct layers with readable attributes, 15 flow arrows, and a re-run replaces its own 46 entities instead of duplicating them. Adds ezdxf to requirements and stores the verified Mubea transform in cfg/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
@echo off
|
||||||
|
REM ================================================================
|
||||||
|
REM SPS_SKEL - TROs in die BricsCAD-Zeichnung einblenden
|
||||||
|
REM ================================================================
|
||||||
|
REM Aufruf:
|
||||||
|
REM bin\tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --check
|
||||||
|
REM bin\tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --flow --fb --legend
|
||||||
|
REM
|
||||||
|
REM Die CSV-Datei und die Zeichnung werden in %SKEL_DATA% gesucht (oder als
|
||||||
|
REM vollstaendiger Pfad angegeben). Das beschriftete DXF liegt in
|
||||||
|
REM %SKEL_RESULTS%, der gepruefte Lagebezug in %SKEL_CFG%.
|
||||||
|
REM
|
||||||
|
REM Die Originalzeichnung wird nie veraendert. DWG wird nicht unterstuetzt.
|
||||||
|
REM ================================================================
|
||||||
|
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
REM Umgebung setzen (SKEL_DATA, SKEL_RESULTS, SKEL_CFG, SKEL_LIB, PYTHONPATH ...)
|
||||||
|
call "%~dp0setenv.bat" >nul
|
||||||
|
|
||||||
|
REM Virtuelle Umgebung nutzen (ezdxf und pydantic werden benoetigt)
|
||||||
|
if exist "%SPS_SKEL%\.venv\Scripts\activate.bat" (
|
||||||
|
call "%SPS_SKEL%\.venv\Scripts\activate.bat"
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Python-Interpreter bestimmen (py-Launcher bevorzugt, sonst python)
|
||||||
|
set "PY=py"
|
||||||
|
py --version >nul 2>&1 || set "PY=python"
|
||||||
|
|
||||||
|
"%PY%" "%SKEL_LIB%\tro_annotate.py" %*
|
||||||
|
set "RC=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
endlocal & exit /b %RC%
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ================================================================
|
||||||
|
# SPS_SKEL - TROs in die BricsCAD-Zeichnung einblenden
|
||||||
|
# ================================================================
|
||||||
|
# Aufruf:
|
||||||
|
# bash bin/tro_annotate.sh --file mubea.csv --dxf 500573_60_1.dxf --check
|
||||||
|
# bash bin/tro_annotate.sh --file mubea.csv --dxf 500573_60_1.dxf --flow --fb --legend
|
||||||
|
#
|
||||||
|
# Die CSV-Datei und die Zeichnung werden in $SKEL_DATA gesucht (oder als
|
||||||
|
# vollstaendiger Pfad angegeben). Das beschriftete DXF liegt in $SKEL_RESULTS,
|
||||||
|
# der gepruefte Lagebezug in $SKEL_CFG.
|
||||||
|
#
|
||||||
|
# Die Originalzeichnung wird nie veraendert. DWG wird nicht unterstuetzt.
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Umgebung setzen (SKEL_DATA, SKEL_RESULTS, SKEL_CFG, SKEL_LIB, PYTHONPATH ...)
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$SCRIPT_DIR/setenv.sh" >/dev/null
|
||||||
|
|
||||||
|
# Virtuelle Umgebung nutzen (ezdxf und pydantic werden benoetigt)
|
||||||
|
if [ -f "$SPS_SKEL/.venv/bin/activate" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$SPS_SKEL/.venv/bin/activate"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Python-Interpreter bestimmen
|
||||||
|
PY="python3"
|
||||||
|
command -v python3 >/dev/null 2>&1 || PY="python"
|
||||||
|
|
||||||
|
exec "$PY" "$SKEL_LIB/tro_annotate.py" "$@"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"mubea.csv|500573_60_1.dxf": {
|
||||||
|
"dx": 50905.678327433096,
|
||||||
|
"dy": 8414.490373798908,
|
||||||
|
"rotation": 0.0,
|
||||||
|
"source": "auto",
|
||||||
|
"anchor": "20 exakte Treffer (<= 50 mm) ueber die Rollen Scanner:4, Separator:31; 20 Stimmen im 100-mm-Raster, 6 Kandidaten geprueft",
|
||||||
|
"matched": 34,
|
||||||
|
"candidates": 35,
|
||||||
|
"residual_max": 452.04557170439375,
|
||||||
|
"residual_mean": 102.6006793267277,
|
||||||
|
"csv_file": "mubea.csv",
|
||||||
|
"dxf_file": "500573_60_1.dxf",
|
||||||
|
"created": "2026-07-30 15:32"
|
||||||
|
},
|
||||||
|
"mubea.csv|Mubea.dxf": {
|
||||||
|
"dx": 50905.67832743315,
|
||||||
|
"dy": 8454.490373798795,
|
||||||
|
"rotation": 0.0,
|
||||||
|
"source": "auto",
|
||||||
|
"anchor": "14 exakte Treffer (<= 50 mm) ueber die Rollen Scanner:4, Separator:31; 14 Stimmen im 100-mm-Raster, 6 Kandidaten geprueft; ACHTUNG mehrdeutig - Kandidat 2 hat ebenfalls 14 exakte Treffer (Restfehler 0.0 mm)",
|
||||||
|
"matched": 27,
|
||||||
|
"candidates": 35,
|
||||||
|
"residual_max": 544.8224945439861,
|
||||||
|
"residual_mean": 122.81360370388875,
|
||||||
|
"csv_file": "mubea.csv",
|
||||||
|
"dxf_file": "Mubea.dxf",
|
||||||
|
"created": "2026-07-30 15:38"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
# -*- 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 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. Die Daten stehen als Attribute am Block:
|
||||||
|
|
||||||
|
TRO_ID TRO03
|
||||||
|
TRO_TYPE 1Sep
|
||||||
|
FB_BLOCK FB_ILS_MTRO_1Sep
|
||||||
|
ITEMS 1x Separator
|
||||||
|
CONFIDENCE mittel
|
||||||
|
|
||||||
|
Damit sind sie in BricsCAD auswertbar und aenderbar - und ein korrigiertes
|
||||||
|
TRO_ID kann spaeter zurueck in die Generierung laufen.
|
||||||
|
|
||||||
|
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 csv as csv_module
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ezdxf
|
||||||
|
from ezdxf.enums import TextEntityAlignment
|
||||||
|
|
||||||
|
from dxf_registration import (
|
||||||
|
ANCHOR_BLOCKS,
|
||||||
|
RegistrationError,
|
||||||
|
Transform,
|
||||||
|
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, get_tro
|
||||||
|
from tro_flow import Analysis, analyse_tros
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Konstanten
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
APPID = "SPS_SKEL_TRO"
|
||||||
|
LAYER_PREFIX = "TRO_"
|
||||||
|
BLOCK_PREFIX = "TRO_SYM_"
|
||||||
|
|
||||||
|
# Zeichnungsgroessen in mm, wenn nicht anders angegeben
|
||||||
|
DEFAULT_MARKER = 300.0
|
||||||
|
DEFAULT_TEXT = 220.0
|
||||||
|
# Restfehler, ab dem der Lagebezug abgelehnt wird
|
||||||
|
DEFAULT_MAX_RESIDUAL = 600.0
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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."""
|
||||||
|
wanted = {name: role for role, names in ANCHOR_BLOCKS.items() for name in names}
|
||||||
|
points: dict[str, list[tuple[float, float]]] = collections.defaultdict(list)
|
||||||
|
for ref in msp.query("INSERT"):
|
||||||
|
role = wanted.get(ref.dxf.name)
|
||||||
|
if role:
|
||||||
|
points[role].append((ref.dxf.insert.x, ref.dxf.insert.y))
|
||||||
|
return dict(points)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_transform(
|
||||||
|
args, elements, msp, cfg_dir: Path, csv_name: str, dxf_name: str,
|
||||||
|
warnings: list[str],
|
||||||
|
) -> Transform:
|
||||||
|
"""Lagebezug bestimmen: Kommandozeile, Konfiguration oder Ermittlung."""
|
||||||
|
csv_points = anchor_points_csv(elements)
|
||||||
|
dxf_points = anchor_points_dxf(msp)
|
||||||
|
|
||||||
|
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 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:
|
||||||
|
doc.layers.add(name, dxfattribs={
|
||||||
|
"true_color": ezdxf.rgb2int(hex_to_rgb(style.fill)),
|
||||||
|
})
|
||||||
|
for key, colour in (("label", "#404040"), ("flow", "#2f5597"),
|
||||||
|
("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 build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool) -> str:
|
||||||
|
"""Blockdefinition fuer einen TRO-Typ anlegen (Marker + Attributdefinitionen)."""
|
||||||
|
name = f"{BLOCK_PREFIX}{type_name}"
|
||||||
|
if name in doc.blocks:
|
||||||
|
return name
|
||||||
|
blk = doc.blocks.new(name=name)
|
||||||
|
|
||||||
|
# Marker: Ring mit Mittelpunkt, Farbe kommt vom Layer des INSERT
|
||||||
|
blk.add_circle((0, 0), marker, dxfattribs={"color": 256})
|
||||||
|
blk.add_circle((0, 0), marker * 0.18, dxfattribs={"color": 256})
|
||||||
|
blk.add_line((-marker, 0), (marker, 0), dxfattribs={"color": 256})
|
||||||
|
blk.add_line((0, -marker), (0, marker), dxfattribs={"color": 256})
|
||||||
|
|
||||||
|
gap = marker * 1.35
|
||||||
|
rows = [
|
||||||
|
("TRO_ID", type_name and "TRO??", text * 1.35, 0),
|
||||||
|
("TRO_TYPE", type_name, text, 0),
|
||||||
|
]
|
||||||
|
if show_fb:
|
||||||
|
rows.append(("FB_BLOCK", "FB", text * 0.85, 0))
|
||||||
|
|
||||||
|
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
|
||||||
|
for tag_name in ("ITEMS", "CONFIDENCE", "SEPARATORS"):
|
||||||
|
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],
|
||||||
|
) -> dict[str, tuple[float, float]]:
|
||||||
|
"""Je TRO einen Blockmarker mit Attributen einfuegen."""
|
||||||
|
placed: dict[str, tuple[float, float]] = {}
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
"TRO_ID": tro.tro_id,
|
||||||
|
"TRO_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),
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def draw_flow(
|
||||||
|
msp, analysis: Analysis, placed: dict[str, tuple[float, float]],
|
||||||
|
layers: dict[str, str], marker: float,
|
||||||
|
) -> int:
|
||||||
|
"""Pfeile fuer den gerichteten Materialfluss zwischen den TROs."""
|
||||||
|
head = marker * 0.9
|
||||||
|
drawn = 0
|
||||||
|
for src, dst in analysis.edges:
|
||||||
|
if src not in placed or dst not in placed:
|
||||||
|
continue
|
||||||
|
x1, y1 = placed[src]
|
||||||
|
x2, y2 = placed[dst]
|
||||||
|
length = math.dist((x1, y1), (x2, y2))
|
||||||
|
if length <= 2 * marker * 1.1:
|
||||||
|
continue
|
||||||
|
ux, uy = (x2 - x1) / length, (y2 - y1) / length
|
||||||
|
# An beiden Enden um den Marker kuerzen, damit der Pfeil ihn nicht deckt
|
||||||
|
sx, sy = x1 + ux * marker * 1.1, y1 + uy * marker * 1.1
|
||||||
|
ex, ey = x2 - ux * marker * 1.1, y2 - uy * marker * 1.1
|
||||||
|
shaft = msp.add_lwpolyline([(sx, sy), (ex, ey)],
|
||||||
|
dxfattribs={"layer": layers["flow"]})
|
||||||
|
tag(shaft, f"{src}->{dst}")
|
||||||
|
# Pfeilspitze als gefuellte Flaeche
|
||||||
|
bx, by = ex - ux * head, ey - uy * head
|
||||||
|
px, py = -uy * head * 0.38, ux * head * 0.38
|
||||||
|
point = msp.add_solid(
|
||||||
|
[(ex, ey), (bx + px, by + py), (bx - px, by - py)],
|
||||||
|
dxfattribs={"layer": layers["flow"]},
|
||||||
|
)
|
||||||
|
tag(point, f"{src}->{dst}")
|
||||||
|
drawn += 1
|
||||||
|
return drawn
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
used = {t.type_name for t in analysis.tros}
|
||||||
|
groups = []
|
||||||
|
for style in STYLES:
|
||||||
|
types = sorted(n for n in used
|
||||||
|
if (get_tro(n).get_group() if get_tro(n) else "ext") == style.group)
|
||||||
|
if types:
|
||||||
|
groups.append((style, types))
|
||||||
|
|
||||||
|
for index, (style, types) in enumerate(groups):
|
||||||
|
y = y0 - index * row
|
||||||
|
box = msp.add_solid(
|
||||||
|
[(x0, y), (x0 + text, y), (x0 + text, y - text), (x0, y - text)],
|
||||||
|
dxfattribs={"layer": layers[style.group]},
|
||||||
|
)
|
||||||
|
tag(box)
|
||||||
|
label = msp.add_text(
|
||||||
|
f"{style.label}: {', '.join(types)}",
|
||||||
|
height=text,
|
||||||
|
dxfattribs={"layer": layers["legend"], "style": "Standard"},
|
||||||
|
)
|
||||||
|
label.set_placement((x0 + text * 1.8, y - text))
|
||||||
|
tag(label)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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']}")
|
||||||
|
print(f"Flusspfeile = {counts['flow']}")
|
||||||
|
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"
|
||||||
|
"\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", required=True, metavar="NAME",
|
||||||
|
help="Originalzeichnung als DXF (Pfad oder Name in "
|
||||||
|
"%%SKEL_DATA%%).")
|
||||||
|
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("--layer-prefix", default=LAYER_PREFIX, metavar="P",
|
||||||
|
help="Praefix der erzeugten Layer. Standard: %(default)s")
|
||||||
|
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("--max-residual", type=float, default=DEFAULT_MAX_RESIDUAL,
|
||||||
|
metavar="MM",
|
||||||
|
help="Groesster zugelassener Restfehler des Lagebezugs. "
|
||||||
|
"Standard: %(default)s")
|
||||||
|
parser.add_argument("--check", action="store_true",
|
||||||
|
help="Nur pruefen und berichten, keine Datei schreiben.")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
csv_file = resolve_input(args.file)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
analysis = analyse_tros(graph, elements)
|
||||||
|
if not analysis.tros:
|
||||||
|
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 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 > args.max_residual:
|
||||||
|
print(f"FEHLER: Restfehler des Lagebezugs {transform.residual_max:.1f} mm "
|
||||||
|
f"ueber der Grenze von {args.max_residual:.1f} mm.", file=sys.stderr)
|
||||||
|
print(f" {transform.describe()}", file=sys.stderr)
|
||||||
|
print(" Mit --offset von Hand setzen oder --max-residual anheben.",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 4
|
||||||
|
|
||||||
|
counts = {"marker": 0, "flow": 0, "removed": 0, "layers": 0, "blocks": 0}
|
||||||
|
|
||||||
|
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, args.layer_prefix)
|
||||||
|
counts["layers"] = len({v for v in layers.values()})
|
||||||
|
|
||||||
|
placed = place_markers(msp, doc, analysis, transform, layers,
|
||||||
|
args.marker_size, args.text_height, args.fb, warnings)
|
||||||
|
counts["marker"] = len(placed)
|
||||||
|
counts["blocks"] = sum(1 for b in doc.blocks if b.name.startswith(BLOCK_PREFIX))
|
||||||
|
|
||||||
|
if args.flow:
|
||||||
|
counts["flow"] = draw_flow(msp, analysis, placed, layers, 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")
|
||||||
|
doc.saveas(str(out_file))
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -3,5 +3,11 @@
|
|||||||
|
|
||||||
pydantic >= 2.0.0
|
pydantic >= 2.0.0
|
||||||
|
|
||||||
|
# DXF lesen und schreiben (tro_annotate). DWG wird nicht unterstuetzt.
|
||||||
|
ezdxf >= 1.4.0
|
||||||
|
|
||||||
|
# Nur fuer Vorschaubilder eines DXF (ezdxf.addons.drawing) - optional:
|
||||||
|
# matplotlib >= 3.8
|
||||||
|
|
||||||
# Noch nicht benoetigt:
|
# Noch nicht benoetigt:
|
||||||
# pytest >= 9.0.0
|
# pytest >= 9.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user