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
This commit is contained in:
Umut CAN
2026-09-21 22:24:03 +03:00
co-authored by Claude Opus 5
parent 2e101b95e2
commit 3c0babfef1
8 changed files with 216 additions and 41 deletions
+25 -16
View File
@@ -11,6 +11,7 @@ from .lines import (
DEFAULT_NUMBER_FORMAT,
LINE_CLASSES,
PID_LINE_LAYERS,
aim_points,
count_crossings,
format_line_number,
label_placement,
@@ -130,10 +131,11 @@ async def existing_pid_lines(backend: AutoCADBackend) -> list[dict]:
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."""
def _anchor(endpoint: dict, aim: tuple[float, float]) -> tuple[tuple[float, float], float | None]:
"""Where the line actually starts/ends: on the circle for radial ports,
leaving along the axis towards ``aim`` (see ``aim_points``)."""
if endpoint["radius"] > 0:
axis = snap_axis(other["x"] - endpoint["x"], other["y"] - endpoint["y"])
axis = snap_axis(aim[0] - endpoint["x"], aim[1] - endpoint["y"])
ux, uy = AXIS[axis]
return (
endpoint["x"] + ux * endpoint["radius"],
@@ -176,8 +178,9 @@ async def draw_line(
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)
s_aim, e_aim = aim_points((start["x"], start["y"]), (end["x"], end["y"]), route_mode)
s_point, s_dir = _anchor(start, s_aim)
e_point, e_dir = _anchor(end, e_aim)
path = route(s_point, s_dir, e_point, e_dir, stub=stub, mode=route_mode)
existing = await existing_pid_lines(backend)
@@ -197,17 +200,23 @@ async def draw_line(
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
if cls.kind == "signal":
# An ISA-5.1 signal line carries no pipe line number: no fabricated
# number, no label, and the process sequence is left alone. A
# verbatim ``line_number`` is still honoured (and labelled).
seq = None
else:
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(
+15
View File
@@ -198,6 +198,21 @@ def route(start, start_dir, end, end_dir, stub: float = 5.0, mode="auto") -> lis
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]
+8 -8
View File
@@ -13,7 +13,7 @@ from .critique import PID_FOCUSES, issues_for
from .drawlines import draw_line
from .graph import build_graph
from .insert import place_symbol
from .lines import AXIS, LINE_CLASSES, count_crossings, route, snap_axis
from .lines import AXIS, LINE_CLASSES, aim_points, count_crossings, route, snap_axis
from .symbols import resolve, transform_port
if TYPE_CHECKING:
@@ -311,9 +311,9 @@ def _plan_lines(norm: dict) -> list[dict]:
raise ValueError(f"{ref}: no port {port!r}; ports: {', '.join(entry['ports'])}")
return entry["ports"][port]
def anchor(ep: dict, other: dict):
def anchor(ep: dict, aim):
if ep["radius"] > 0:
axis = snap_axis(other["x"] - ep["x"], other["y"] - ep["y"])
axis = snap_axis(aim[0] - ep["x"], aim[1] - ep["y"])
ux, uy = AXIS[axis]
return (ep["x"] + ux * ep["radius"], ep["y"] + uy * ep["radius"]), axis
return (ep["x"], ep["y"]), ep["direction_deg"]
@@ -322,11 +322,11 @@ def _plan_lines(norm: dict) -> list[dict]:
for index, (where, line) in enumerate(_all_lines(norm)):
try:
start, end = endpoint(line["from"]), endpoint(line["to"])
s, sd = anchor(start, end)
e, ed = anchor(end, start)
vertices = route(
s, sd, e, ed, stub=float(line.get("stub", 5.0)), mode=line.get("route", "auto")
)
mode = line.get("route", "auto")
s_aim, e_aim = aim_points((start["x"], start["y"]), (end["x"], end["y"]), mode)
s, sd = anchor(start, s_aim)
e, ed = anchor(end, e_aim)
vertices = route(s, sd, e, ed, stub=float(line.get("stub", 5.0)), mode=mode)
except ValueError as exc:
raise ValueError(f"{where}: {exc}") from exc
crossings = count_crossings(vertices, [p["vertices"] for p in planned])