tro_annotate: Lagebezug ueber die TeileId, Ablaufbeschreibung CSV/DXF -> SCL

dxf_registration.derive_from_ids: Zeichnungen aus dxfmakros tragen an jedem
Layout-Block das Attribut ID = TeileId des CSV. tro_annotate paart CSV-
Separatoren mit den Separator-Bloecken gleicher ID (sonst Scanner) und nimmt
den Median-Versatz - eindeutig statt Abstimmung, bei jedem Lauf neu, gilt
auch fuer eine verschobene Kopie der Zeichnung. Vorrang vor abgelegtem und
abgestimmtem Bezug; --offset bleibt. Parameter id_spread_mm, id_min_share in
cfg/parameter.ini [dxf_registration].

Dematic: gemeinsame Zeichnung 0/0, Mechanik-Zeichnung dematic.dxf
+466,1/-163549,3 mm. Die Abstimmung rastete vorher auf den Scanner-Bloecken
ein (Einfuegepunkt 360 mm neben der CSV-Position) und lag 360 mm daneben.

Gesperrte Ausgabedatei (in BricsCAD geoeffnet) gibt eine Meldung statt
Traceback. Neu: doc/Workflow_CSV_DXF_to_SCL.md - der ganze Ablauf vom
CSV-Export und der Zeichnung bis zur SCL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 14:59:16 +02:00
parent e5d1fce41f
commit 9ec1022fb1
6 changed files with 512 additions and 10 deletions
+53
View File
@@ -66,6 +66,7 @@ __all__ = [
"RegistrationError",
"ANCHOR_BLOCKS",
"derive_transform",
"derive_from_ids",
"verify_transform",
"load_transform",
"save_transform",
@@ -97,6 +98,8 @@ FIT_TOL = get_float(_P, "fit_tol")
DEFAULT_MATCH_TOL = get_float(_P, "default_match_tol")
MIN_INLIERS = get_int(_P, "min_inliers")
ROTATION_WARN_DEG = get_float(_P, "rotation_warn_deg")
ID_SPREAD_MM = get_float(_P, "id_spread_mm")
ID_MIN_SHARE = get_float(_P, "id_min_share")
class RegistrationError(RuntimeError):
@@ -304,6 +307,56 @@ def derive_transform(
return transform
def derive_from_ids(
csv_by_id: dict[str, tuple[str, float, float]],
dxf_by_id: dict[str, tuple[str, float, float]],
csv_file: str = "",
dxf_file: str = "",
) -> Transform | None:
"""
Lagebezug ueber die TeileId: CSV-Objekt und DXF-Block mit derselben ID.
Mit dxfmakros gebaute Zeichnungen tragen an jedem Layout-Block das
Attribut ID - dieselbe Nummer, die der CSV-Export als TeileId fuehrt. Das
ist eine eindeutige Zuordnung, keine Abstimmung ueber Raster, und gilt
genauso fuer eine verschobene Kopie der Zeichnung (nur Mechanik als
Hintergrund) wie fuer die Zeichnung, aus der das CSV stammt.
Parameter:
csv_by_id: TeileId -> (Rolle, x, y) aus dem CSV (Rolle wie in
ANCHOR_BLOCKS: "Separator", "Scanner").
dxf_by_id: ID-Attribut -> (Rolle, x, y) der Bloecke im DXF.
Rueckgabe:
Transform (DXF -> CSV, ohne Drehung) oder None, wenn keine Rolle
genug uebereinstimmende Paare hat. Die Rollen werden in der
Reihenfolge von ANCHOR_BLOCKS versucht: der Separator-Block sitzt
fast genau auf der CSV-Position (Dematic: 60 mm), der Scanner-Block
360 mm daneben. Ausgedehnte Objekte (Kreisel, Strecken) zaehlen
nicht - ihr Einfuegepunkt liegt je nach Lage weit vom CSV-Mittelpunkt.
Eine Rolle gilt, wenn mindestens MIN_INLIERS Paare und ID_MIN_SHARE
aller Paare hoechstens ID_SPREAD_MM vom Median entfernt liegen.
"""
for rolle in ANCHOR_BLOCKS:
paare = [(c[1] - d[1], c[2] - d[2])
for i, c in csv_by_id.items()
if c[0] == rolle and (d := dxf_by_id.get(i)) is not None and d[0] == rolle]
if len(paare) < MIN_INLIERS:
continue
mdx = sorted(p[0] for p in paare)[len(paare) // 2]
mdy = sorted(p[1] for p in paare)[len(paare) // 2]
passend = [p for p in paare if math.hypot(p[0] - mdx, p[1] - mdy) <= ID_SPREAD_MM]
if len(passend) < MIN_INLIERS or len(passend) < ID_MIN_SHARE * len(paare):
continue
return Transform(
dx=mdx, dy=mdy, source="ids", csv_file=csv_file, dxf_file=dxf_file,
anchor=(f"TeileId = ID-Attribut: {len(paare)} {rolle}-Paare, "
f"{len(passend)} davon hoechstens {ID_SPREAD_MM:.0f} mm um den Median"),
created=datetime.now().strftime("%Y-%m-%d %H:%M"),
)
return None
def verify_transform(
transform: Transform,
csv_points: Points,
+58 -2
View File
@@ -101,6 +101,7 @@ from dxf_registration import (
ANCHOR_BLOCKS,
RegistrationError,
Transform,
derive_from_ids,
derive_transform,
load_transform,
save_transform,
@@ -229,14 +230,63 @@ def anchor_points_dxf(msp) -> dict[str, list[tuple[float, float]]]:
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, Konfiguration oder Ermittlung."""
"""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(","))
@@ -1241,7 +1291,13 @@ def main(argv: list[str] | None = None) -> int:
args.text_height, csv_file.name)
out_file = results / (args.out or f"{csv_file.stem}_annotated.dxf")
doc.saveas(str(out_file))
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(