Files
autocad-mcp-pro/engineering/pid/lines.py
T
Umut CANandClaude Opus 5 3c0babfef1 fix(pid): signal lines carry no fabricated number; a bubble exits towards the first waypoint
An ISA-5.1 signal line has no pipe line number, yet every pneumatic /
electric line got the bare sequence as its number and a label, and ate a
process sequence number. Signal classes now leave line_number None (a
verbatim one is still honoured), seq None, no label. A radial port aimed
at the far end even with waypoints, so the first segment cut through the
bubble; aim_points (lines.py) aims at the first/last waypoint for both
draw_line and the dry run.

Merge note: benchmarks/adapters/autocad_mcp_pro.py (final group's file)
had its two _task_pid_roundtrip assertion lines moved to the new truth —
the signal row is unnumbered, the connector row is 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:24:03 +03:00

324 lines
12 KiB
Python

# engineering/pid/lines.py
"""Line classes (ISA-5.1 Table 5.3.1), orthogonal routing, labels, markers, crossings."""
from __future__ import annotations
import math
import string
from dataclasses import dataclass
@dataclass(frozen=True)
class LineClass:
layer: str
linetype: str | None # None = ByLayer
marker: str | None # marker symbol placed on long segments
arrow: bool # flow arrow at the end by default
kind: str # "process" | "signal"
LINE_CLASSES: dict[str, LineClass] = {
"process_major": LineClass("PROCESS-PIPING-MAIN", None, None, True, "process"),
"process_minor": LineClass("PROCESS-PIPING-SECONDARY", None, None, True, "process"),
"utility": LineClass("UTILITY-LINE", None, None, True, "process"),
"pneumatic": LineClass(
"INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_pneumatic", False, "signal"
),
"electric": LineClass("INSTRUMENT-LINE-SIGNAL", None, None, False, "signal"),
"hydraulic": LineClass(
"INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_hydraulic", False, "signal"
),
"capillary": LineClass(
"INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_capillary", False, "signal"
),
"data": LineClass("INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_data", False, "signal"),
}
#: Layer → class when a line carries no payload. ``None`` = decide by linetype.
LAYER_TO_CLASS: dict[str, str | None] = {
"PROCESS-PIPING-MAIN": "process_major",
"PROCESS-PIPING-SECONDARY": "process_minor",
"UTILITY-LINE": "utility",
"ELECTRICAL-LINE": "electric",
"INSTRUMENT-LINE-SIGNAL": None,
}
PID_LINE_LAYERS = frozenset(LAYER_TO_CLASS)
AXIS = {0.0: (1.0, 0.0), 90.0: (0.0, 1.0), 180.0: (-1.0, 0.0), 270.0: (0.0, -1.0)}
DEFAULT_NUMBER_FORMAT = "{size}-{service}-{seq}-{spec}-{insulation}"
_EPS = 1e-9
_DIR_TOL = 1e-6 # degrees of drift still read as an axis
Point = tuple[float, float]
def snap_axis(dx: float, dy: float) -> float:
if abs(dx) >= abs(dy):
return 0.0 if dx >= 0 else 180.0
return 90.0 if dy >= 0 else 270.0
def axis_direction(name: str, direction_deg) -> float:
"""Normalise ``direction_deg`` to one of the four AXIS keys, or raise ``ValueError``.
Tolerates the float drift an engine round trip can add (1e-6 deg); anything
else — 45, 90.5, nan — is a genuinely non-orthogonal port and is refused."""
value = float(direction_deg)
if math.isfinite(value):
wrapped = value % 360.0
for key in AXIS:
if abs(wrapped - key) < _DIR_TOL or abs(wrapped - key - 360.0) < _DIR_TOL:
return key
raise ValueError(
f"{name} {value} is not an axis direction (0/90/180/270); "
"rotate the symbol to a multiple of 90 or pass waypoints"
)
def _unit(direction_deg: float) -> Point:
return AXIS[axis_direction("direction", direction_deg)]
def _finite_point(name: str, p) -> Point:
q = (float(p[0]), float(p[1]))
for axis, value in zip("xy", q, strict=True):
if not math.isfinite(value):
raise ValueError(f"{name}.{axis} is {value}; coordinates must be finite")
return q
def _dist(a: Point, b: Point) -> float:
return math.hypot(b[0] - a[0], b[1] - a[1])
def path_length(points) -> float:
return sum(_dist(a, b) for a, b in zip(points, points[1:], strict=False))
def simplify(points) -> list[Point]:
"""Drop repeated points and merge collinear consecutive segments."""
pts: list[Point] = []
for p in points:
q = (float(p[0]), float(p[1]))
if not pts or _dist(pts[-1], q) > _EPS:
pts.append(q)
out: list[Point] = []
for p in pts:
if len(out) >= 2:
a, b = out[-2], out[-1]
cross = (b[0] - a[0]) * (p[1] - b[1]) - (b[1] - a[1]) * (p[0] - b[0])
dot = (b[0] - a[0]) * (p[0] - b[0]) + (b[1] - a[1]) * (p[1] - b[1])
if abs(cross) < _EPS and dot > 0:
out[-1] = p
continue
out.append(p)
return out
def _direction(a: Point, b: Point) -> Point:
length = _dist(a, b)
return ((b[0] - a[0]) / length, (b[1] - a[1]) / length)
def _same(u: Point, v: Point) -> bool:
return abs(u[0] - v[0]) < 1e-6 and abs(u[1] - v[1]) < 1e-6
def _validate(candidate, ds: float | None, de: float | None, stub: float) -> list[Point] | None:
pts = simplify(candidate)
if len(pts) < 2:
return None
segs = list(zip(pts, pts[1:], strict=False))
straight = len(segs) == 1
if ds is not None and not _same(_direction(*segs[0]), _unit(ds)):
return None
if de is not None:
ue = _unit(de)
if not _same(_direction(*segs[-1]), (-ue[0], -ue[1])):
return None
minimum = 0.01 if straight else max(stub, 0.01)
if any(_dist(a, b) < minimum - _EPS for a, b in segs):
return None
for (a1, b1), (a2, b2) in zip(segs, segs[1:], strict=False):
u, v = _direction(a1, b1), _direction(a2, b2)
if abs(u[0] * v[0] + u[1] * v[1]) > 1e-6:
return None
return pts
def route(start, start_dir, end, end_dir, stub: float = 5.0, mode="auto") -> list[Point]:
"""Orthogonal path from ``start`` (leaving along ``start_dir``) to ``end``
(arriving against ``end_dir``). ``None`` directions are radial: the axis
towards the other end. Raises ``ValueError`` when no candidate honours the stub."""
s = _finite_point("start", start)
e = _finite_point("end", end)
if isinstance(mode, (list, tuple)):
path = [s] + [_finite_point(f"waypoints[{i}]", p) for i, p in enumerate(mode)] + [e]
for a, b in zip(path, path[1:], strict=False):
if _dist(a, b) < 0.01:
raise ValueError("waypoints repeat a point (segment shorter than 0.01 mm)")
return path
if _dist(s, e) < 0.01:
raise ValueError("zero-length line: both ends are the same point")
if mode == "direct":
return [s, e]
if mode != "auto":
raise ValueError("route must be 'auto', 'direct' or a list of waypoints")
stub = float(stub)
if not math.isfinite(stub) or stub < 0:
raise ValueError(f"stub must be a finite length >= 0, not {stub}")
ds = (
snap_axis(e[0] - s[0], e[1] - s[1])
if start_dir is None
else axis_direction("start_dir", start_dir)
)
de = (
snap_axis(s[0] - e[0], s[1] - e[1])
if end_dir is None
else axis_direction("end_dir", end_dir)
)
us, ue = _unit(ds), _unit(de)
s1 = (s[0] + us[0] * stub, s[1] + us[1] * stub)
e1 = (e[0] + ue[0] * stub, e[1] + ue[1] * stub)
xm, ym = (s1[0] + e1[0]) / 2.0, (s1[1] + e1[1]) / 2.0
candidates = [
[s, e],
[s, (e[0], s[1]), e],
[s, (s[0], e[1]), e],
[s, (xm, s[1]), (xm, e[1]), e],
[s, (s[0], ym), (e[0], ym), e],
[s, s1, (s1[0], e1[1]), e1, e],
[s, s1, (e1[0], s1[1]), e1, e],
]
valid = [p for p in (_validate(c, ds, de, stub) for c in candidates) if p]
if not valid:
raise ValueError(
f"no orthogonal route from {s} to {e} honours a {stub} mm stub on both ports; "
"shorten `stub` or pass waypoints"
)
valid.sort(key=lambda p: (len(p) - 2, path_length(p)))
return valid[0]
def aim_points(start, end, mode) -> tuple[Point, Point]:
"""What each end of a line aims at: the other end, or — with waypoints —
the first waypoint for the start and the last one for the end.
A radial (bubble) port leaves its circle along the axis towards its aim
point. Aiming at the far end while the route is a waypoint list put the
exit on the wrong side of the bubble, so the first segment cut through it.
"""
if isinstance(mode, (list, tuple)) and len(mode) > 0:
return _finite_point("waypoints[0]", mode[0]), _finite_point(
f"waypoints[{len(mode) - 1}]", mode[-1]
)
return _finite_point("end", end), _finite_point("start", start)
def label_placement(vertices, offset: float = 1.5) -> tuple[float, float, float, int]:
"""Midpoint of the longest segment, offset to its left, readable rotation."""
pts = [(float(x), float(y)) for x, y in vertices]
segs = list(zip(pts, pts[1:], strict=False))
index = max(range(len(segs)), key=lambda i: _dist(*segs[i]))
(x0, y0), (x1, y1) = segs[index]
angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) % 360.0
if 90.0 < angle <= 270.0:
angle = (angle - 180.0) % 360.0
ux, uy = math.cos(math.radians(angle)), math.sin(math.radians(angle))
mx, my = (x0 + x1) / 2.0, (y0 + y1) / 2.0
return (mx - uy * offset, my + ux * offset, round(angle, 6), index)
def marker_positions(vertices, min_len: float = 15.0) -> list[tuple[float, float, float]]:
pts = [(float(x), float(y)) for x, y in vertices]
out = []
for (x0, y0), (x1, y1) in zip(pts, pts[1:], strict=False):
if _dist((x0, y0), (x1, y1)) < min_len:
continue
angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) % 360.0
out.append(((x0 + x1) / 2.0, (y0 + y1) / 2.0, round(angle, 6)))
return out
def _orient(a: Point, b: Point, c: Point) -> float:
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
def segments_cross(p1, p2, q1, q2) -> bool:
"""Proper crossing only: shared endpoints and T-touches are junctions, not crossings."""
d1, d2 = _orient(q1, q2, p1), _orient(q1, q2, p2)
d3, d4 = _orient(p1, p2, q1), _orient(p1, p2, q2)
return d1 * d2 < -_EPS and d3 * d4 < -_EPS
def count_crossings(vertices, others) -> int:
pts = [(float(x), float(y)) for x, y in vertices]
count = 0
for a, b in zip(pts, pts[1:], strict=False):
for other in others:
opts = [(float(x), float(y)) for x, y in other]
for c, d in zip(opts, opts[1:], strict=False):
if segments_cross(a, b, c, d):
count += 1
return count
_KNOWN_FIELDS = {"size", "service", "seq", "spec", "insulation", "area", "unit"}
def _parse_token(token: str) -> list[tuple[str, str | None, str | None, str | None]]:
"""``string.Formatter`` parse of one ``-``-separated token, with every malformed
or unsupported placeholder refused by name — nothing braced is ever copied through."""
try:
parts = list(string.Formatter().parse(token))
except ValueError as exc:
raise ValueError(f"line-number format token {token!r} is malformed: {exc}") from exc
fields = [part for part in parts if part[1] is not None]
if len(fields) > 1:
raise ValueError(
f"line-number format token {token!r} holds {len(fields)} fields; "
"a token may hold at most one field"
)
for _literal, name, _spec, conversion in fields:
if name == "" or name.isdigit():
raise ValueError(
f"line-number format token {token!r} uses a positional field; "
f"name one of: {', '.join(sorted(_KNOWN_FIELDS))}"
)
if name not in _KNOWN_FIELDS:
raise ValueError(
f"unknown line-number field {{{name}}}; known: {', '.join(sorted(_KNOWN_FIELDS))}"
)
if conversion is not None:
raise ValueError(
f"line-number format token {token!r} uses a conversion (!{conversion}); "
"only a format spec such as {seq:04d} is supported"
)
return parts
def _render_token(token: str, fields: dict) -> str | None:
"""The token with its field filled, or ``None`` when that field is empty."""
out: list[str] = []
for literal, name, spec, _conversion in _parse_token(token):
out.append(literal)
if name is None:
continue
value = fields.get(name)
if value is None or value == "":
return None
try:
out.append(format(value, spec or ""))
except (ValueError, TypeError) as exc:
raise ValueError(
f"line-number format token {token!r} cannot format {name}={value!r}: {exc}"
) from exc
return "".join(out)
def format_line_number(fmt: str, **fields) -> str:
"""Fill ``fmt`` (``-``-separated tokens) and drop the empty fields with their separators.
A token is literal text, or one ``{field}`` (optionally ``{field:spec}``) with
literal text around it; a token whose field is empty drops out whole."""
rendered = (_render_token(token, fields) for token in fmt.split("-"))
return "-".join(text for text in rendered if text is not None)