Closes the four verified BLOCKING findings on pid_graph. 1. scope="all" attached across layouts: the port grid, bbox candidates, vertex grid, junctions and text lookup were pooled from every space. The grid is now keyed by (space, cell) and every lookup filters by the edge's space; junctions carry `space`. 2. A mirrored INSERT (extrusion -Z, as MIRROR3D / foreign DXFs store it) got its catalogue ports on the unmirrored image at 1.0. Both engines report `mirrored` on INSERT properties (ezdxf: extrusion z < 0; COM: Normal z < 0; default False); transform_port takes `mirrored` and applies x -> -x about the WCS insertion point after rotation/scale, direction (180 - d) mod 360 — cross-checked against Insert.matrix44. resolve_endpoint passes it through too, so draw_line starts on the mirror image as well. 3. The text-tag fallback searched a full circle around the box top, so a line label or note below the symbol beat the tag above it. Candidates must now sit at or above top - height/2 (spec §9.2 step 6), nearest wins, and line-number texts are never equipment tags. 4. A non-uniform INSERT scale re-ran the transform with the X factor and set `inferred` (reserved for foreign-block ports). transform_port gains `strict=False` for the reader: point ports scale per axis exactly; a radial port keeps min(sx, sy)·r and the node drops to 0.6 with notes ["non-uniform scale"]. Node dicts gain `y_scale`, `mirrored` and `notes` (default []). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""``pid_line_draw``: resolve ports, route, draw, label, mark, tag with XDATA."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import TYPE_CHECKING
|
|
|
|
from .insert import ensure_layer, insert_symbol
|
|
from .lines import (
|
|
AXIS,
|
|
DEFAULT_NUMBER_FORMAT,
|
|
LINE_CLASSES,
|
|
PID_LINE_LAYERS,
|
|
count_crossings,
|
|
format_line_number,
|
|
label_placement,
|
|
marker_positions,
|
|
route,
|
|
snap_axis,
|
|
)
|
|
from .symbols import Port, resolve, transform_port
|
|
from .xdata import line_payload, marker_payload, read_payload, write_payload
|
|
|
|
if TYPE_CHECKING:
|
|
from backends.base import AutoCADBackend
|
|
|
|
TEXT_HEIGHT = 2.5
|
|
LABEL_LAYER = "PROCESS-LINE-TEXT"
|
|
_INSERT_TYPES = {"INSERT", "BLOCKREFERENCE"}
|
|
# ezdxf reports a lightweight polyline as LWPOLYLINE; the COM engine derives the
|
|
# type from the ActiveX ObjectName, and a live LWPOLYLINE is ``AcDbPolyline``
|
|
# (measured on AutoCAD 2026 — see backends/com_backend.py), so it arrives as
|
|
# POLYLINE, with the heavy kind as 2DPOLYLINE. Same set the graph reader keeps.
|
|
_POLY_TYPES = {"LWPOLYLINE", "POLYLINE", "2DPOLYLINE"}
|
|
_LINE_TYPES = {"LINE"}
|
|
_PAGE = 1000
|
|
|
|
|
|
async def resolve_endpoint(backend: AutoCADBackend, ref: dict) -> dict:
|
|
"""``{handle, port}`` → the port in WCS (payload ports + INSERT transform);
|
|
``{x, y}`` → a free point."""
|
|
if not isinstance(ref, dict):
|
|
raise ValueError("endpoint must be {handle, port} or {x, y}")
|
|
if "handle" in ref:
|
|
handle = str(ref["handle"])
|
|
info = await backend.entity_get(handle)
|
|
block_name = str(info.properties.get("block_name", ""))
|
|
if block_name.startswith("PID_MARKER_"):
|
|
raise ValueError(f"{handle} is a marker/arrow — decoration on a line, not an endpoint")
|
|
payload = await read_payload(backend, handle)
|
|
if info.type not in _INSERT_TYPES or payload is None or payload.get("kind") != "symbol":
|
|
raise ValueError(f"{handle} is not a P&ID symbol placed by pid_symbol_insert")
|
|
ports = payload["ports"]
|
|
port_name = ref.get("port")
|
|
if port_name is None:
|
|
if len(ports) == 1:
|
|
port_name = next(iter(ports))
|
|
elif payload.get("family") == "instrument" and "signal" in ports:
|
|
port_name = "signal"
|
|
else:
|
|
raise ValueError(f"{handle}: choose a port: {', '.join(ports)}")
|
|
if port_name not in ports:
|
|
raise ValueError(f"{handle} has no port {port_name!r}; ports: {', '.join(ports)}")
|
|
lx, ly, ldir, kind, radius = ports[port_name]
|
|
ins = info.properties["insertion"]
|
|
x_scale = float(info.properties.get("x_scale", 1.0))
|
|
# The full INSERT transform: a mirrored symbol (``entity_mirror`` writes
|
|
# ``y_scale = -1``; MIRROR3D and foreign DXFs store extrusion -Z, which
|
|
# the engines report as ``mirrored``) has its ports on the mirrored
|
|
# geometry, not on the unmirrored one; a stretched symbol is refused
|
|
# by ``transform_port``.
|
|
try:
|
|
world = transform_port(
|
|
Port(port_name, float(lx), float(ly), ldir, kind, float(radius)),
|
|
float(ins[0]),
|
|
float(ins[1]),
|
|
float(info.properties.get("rotation_deg", 0.0)),
|
|
x_scale,
|
|
float(info.properties.get("y_scale", x_scale)),
|
|
bool(info.properties.get("mirrored", False)),
|
|
)
|
|
except ValueError as exc:
|
|
raise ValueError(f"{handle}: {exc}") from exc
|
|
return {**world, "handle": handle, "port": port_name, "block_name": block_name}
|
|
if "x" in ref and "y" in ref:
|
|
return {
|
|
"name": None,
|
|
"x": float(ref["x"]),
|
|
"y": float(ref["y"]),
|
|
"direction_deg": None,
|
|
"kind": None,
|
|
"radius": 0.0,
|
|
"inferred": False,
|
|
"handle": None,
|
|
"port": None,
|
|
"block_name": None,
|
|
}
|
|
raise ValueError("endpoint must be {handle, port} or {x, y}")
|
|
|
|
|
|
def _vertices(info) -> list[tuple[float, float]]:
|
|
if info.type in _LINE_TYPES:
|
|
s, e = info.properties["start"], info.properties["end"]
|
|
return [(float(s[0]), float(s[1])), (float(e[0]), float(e[1]))]
|
|
return [(float(p[0]), float(p[1])) for p in info.properties.get("points") or []]
|
|
|
|
|
|
async def existing_pid_lines(backend: AutoCADBackend) -> list[dict]:
|
|
"""Every LINE/polyline on a P&ID line layer, with its payload when it has one.
|
|
|
|
Listed without a type filter: the two engines name a lightweight polyline
|
|
differently (``_POLY_TYPES``), and a filter spelled for one of them is blind
|
|
on the other — every auto-built number would restart at 1 and no crossing
|
|
or port reuse would ever be reported there.
|
|
"""
|
|
out = []
|
|
offset = 0
|
|
while True:
|
|
page = await backend.entity_list(limit=_PAGE, offset=offset)
|
|
for info in page:
|
|
if info.type not in _POLY_TYPES | _LINE_TYPES:
|
|
continue
|
|
if info.layer.upper() not in PID_LINE_LAYERS:
|
|
continue
|
|
payload = await read_payload(backend, info.handle) if info.type in _POLY_TYPES else None
|
|
out.append({"handle": info.handle, "vertices": _vertices(info), "payload": payload})
|
|
if len(page) < _PAGE:
|
|
break
|
|
offset += _PAGE
|
|
return out
|
|
|
|
|
|
def _anchor(endpoint: dict, other: dict) -> tuple[tuple[float, float], float | None]:
|
|
"""Where the line actually starts/ends: on the circle for radial ports."""
|
|
if endpoint["radius"] > 0:
|
|
axis = snap_axis(other["x"] - endpoint["x"], other["y"] - endpoint["y"])
|
|
ux, uy = AXIS[axis]
|
|
return (
|
|
endpoint["x"] + ux * endpoint["radius"],
|
|
endpoint["y"] + uy * endpoint["radius"],
|
|
), axis
|
|
return (endpoint["x"], endpoint["y"]), endpoint["direction_deg"]
|
|
|
|
|
|
def _ref(endpoint: dict) -> dict | None:
|
|
return {"handle": endpoint["handle"], "port": endpoint["port"]} if endpoint["handle"] else None
|
|
|
|
|
|
def _segment_angle(a: tuple[float, float], b: tuple[float, float]) -> float:
|
|
(ax0, ay0), (ax1, ay1) = a, b
|
|
if abs(ax1 - ax0) < 1e-9 or abs(ay1 - ay0) < 1e-9:
|
|
return snap_axis(ax1 - ax0, ay1 - ay0)
|
|
return math.degrees(math.atan2(ay1 - ay0, ax1 - ax0)) % 360.0
|
|
|
|
|
|
async def draw_line(
|
|
backend: AutoCADBackend,
|
|
from_: dict,
|
|
to: dict,
|
|
line_class: str = "process_major",
|
|
route_mode="auto",
|
|
stub: float = 5.0,
|
|
line_number: str | None = None,
|
|
size: str | None = None,
|
|
service: str | None = None,
|
|
spec: str | None = None,
|
|
insulation: str | None = None,
|
|
number_format: str | None = None,
|
|
label: bool = True,
|
|
arrow: bool | None = None,
|
|
) -> dict:
|
|
if line_class not in LINE_CLASSES:
|
|
raise ValueError(f"line_class must be one of {', '.join(LINE_CLASSES)}, got {line_class!r}")
|
|
cls = LINE_CLASSES[line_class]
|
|
start = await resolve_endpoint(backend, from_)
|
|
end = await resolve_endpoint(backend, to)
|
|
if start["handle"] and start["handle"] == end["handle"] and start["port"] == end["port"]:
|
|
raise ValueError("from and to are the same port")
|
|
s_point, s_dir = _anchor(start, end)
|
|
e_point, e_dir = _anchor(end, start)
|
|
path = route(s_point, s_dir, e_point, e_dir, stub=stub, mode=route_mode)
|
|
|
|
existing = await existing_pid_lines(backend)
|
|
crossings = count_crossings(path, [ln["vertices"] for ln in existing])
|
|
used: set[tuple[str, str]] = set()
|
|
max_seq = 0
|
|
for ln in existing:
|
|
payload = ln["payload"] or {}
|
|
for key in ("from", "to"):
|
|
ref = payload.get(key)
|
|
if ref:
|
|
used.add((ref["handle"], ref["port"]))
|
|
if isinstance(payload.get("seq"), int):
|
|
max_seq = max(max_seq, payload["seq"])
|
|
port_reuse = [
|
|
{"handle": ep["handle"], "port": ep["port"]}
|
|
for ep in (start, end)
|
|
if ep["handle"] and (ep["handle"], ep["port"]) in used
|
|
]
|
|
seq = max_seq + 1
|
|
if line_number is None:
|
|
fmt = number_format or DEFAULT_NUMBER_FORMAT
|
|
fields = {
|
|
"size": size,
|
|
"service": service,
|
|
"seq": seq,
|
|
"spec": spec,
|
|
"insulation": insulation,
|
|
}
|
|
line_number = format_line_number(fmt, **fields) or None
|
|
|
|
await ensure_layer(backend, cls.layer)
|
|
poly = await backend.entity_create_polyline(
|
|
[list(p) for p in path], closed=False, layer=cls.layer
|
|
)
|
|
if cls.linetype:
|
|
await backend.entity_set_properties(poly.handle, linetype=cls.linetype)
|
|
await write_payload(
|
|
backend,
|
|
poly.handle,
|
|
line_payload(
|
|
line_class, line_number, size, service, spec, insulation, seq, _ref(start), _ref(end)
|
|
),
|
|
)
|
|
|
|
label_handle = None
|
|
if label and line_number:
|
|
await ensure_layer(backend, LABEL_LAYER)
|
|
lx, ly, rot, _index = label_placement(path)
|
|
shift = len(line_number) * TEXT_HEIGHT * 0.7 / 2.0
|
|
lx -= math.cos(math.radians(rot)) * shift
|
|
ly -= math.sin(math.radians(rot)) * shift
|
|
text = await backend.entity_create_text(
|
|
line_number, lx, ly, TEXT_HEIGHT, rot, layer=LABEL_LAYER
|
|
)
|
|
label_handle = text.handle
|
|
|
|
want_arrow = cls.arrow if arrow is None else arrow
|
|
arrow_handle = None
|
|
if want_arrow:
|
|
angle = _segment_angle(path[-2], path[-1])
|
|
ax1, ay1 = path[-1]
|
|
placed = await insert_symbol(
|
|
backend,
|
|
resolve("arrow_flow"),
|
|
ax1,
|
|
ay1,
|
|
angle,
|
|
1.0,
|
|
None,
|
|
cls.layer,
|
|
marker_payload(poly.handle),
|
|
)
|
|
arrow_handle = placed["handle"]
|
|
|
|
marker_handles: list[str] = []
|
|
if cls.marker:
|
|
marker_spec = resolve(cls.marker)
|
|
for mx, my, angle in marker_positions(path):
|
|
placed = await insert_symbol(
|
|
backend,
|
|
marker_spec,
|
|
mx,
|
|
my,
|
|
angle,
|
|
1.0,
|
|
None,
|
|
cls.layer,
|
|
marker_payload(poly.handle),
|
|
)
|
|
marker_handles.append(placed["handle"])
|
|
|
|
return {
|
|
"handle": poly.handle,
|
|
"vertices": [[x, y] for x, y in path],
|
|
"line_class": line_class,
|
|
"layer": cls.layer,
|
|
"line_number": line_number,
|
|
"from": {
|
|
"handle": start["handle"],
|
|
"port": start["port"],
|
|
"x": s_point[0],
|
|
"y": s_point[1],
|
|
},
|
|
"to": {"handle": end["handle"], "port": end["port"], "x": e_point[0], "y": e_point[1]},
|
|
"port_reuse": port_reuse,
|
|
"crossings": crossings,
|
|
"label_handle": label_handle,
|
|
"arrow_handle": arrow_handle,
|
|
"marker_handles": marker_handles,
|
|
"backend": backend.name,
|
|
}
|