From e8f1a666f87d776fcaa2c9b945475b05c93d3569 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 15 Sep 2026 23:26:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(pid):=20pid=5Fline=5Fdraw=20=E2=80=94=20po?= =?UTF-8?q?rt-to-port=20orthogonal=20lines=20with=20class,=20number,=20mar?= =?UTF-8?q?kers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 2 +- discovery/aliases.py | 17 +++ docs/tool-inventory.json | 18 ++- engineering/pid/drawlines.py | 270 +++++++++++++++++++++++++++++++++++ server.py | 73 +++++++++- tests/test_pid_line_draw.py | 140 ++++++++++++++++++ tests/test_tool_registry.py | 14 +- 7 files changed, 523 insertions(+), 11 deletions(-) create mode 100644 engineering/pid/drawlines.py create mode 100644 tests/test_pid_line_draw.py diff --git a/README.md b/README.md index 02aa31a..c478180 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 159 tools · 6 resources · 5 prompt templates · 1369 collected tests. +> **v1.5 release snapshot:** 160 tools · 6 resources · 5 prompt templates · 1369 collected tests. > 154 is the **registered** count; a default install advertises 149 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. diff --git a/discovery/aliases.py b/discovery/aliases.py index 2be4c70..d804256 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -69,6 +69,9 @@ class ToolAliases: # BLOCK -- one command makes a definition either from selected objects # (block_create_from_entities) or from scratch with attribute # definitions (block_define); a drafter typing it could mean either. +# PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run +# between two ports (pid_line_draw), which is an LWPOLYLINE with a +# class, a number and markers; a drafter typing it could mean either. # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( @@ -80,6 +83,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( "LAYER", "LAYOUT", "MEASUREGEOM", + "PLINE", "QSELECT", "SETVAR", "ZOOM", @@ -1367,6 +1371,19 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "process equipment symbol", ), ), + "pid_line_draw": ToolAliases( + acad=("PLINE",), + synonyms=( + "process line", + "pipe run", + "signal line", + "connect equipment", + "instrument line", + "line number", + "connect the pump to the vessel", + "pneumatic signal", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index a3d53da..b1751ee 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 159, + "tools": 160, "resources": 6, "prompts": 5, "groups": 20 @@ -21,7 +21,7 @@ "entity_query": 9, "layers": 14, "layouts": 12, - "pid": 2, + "pid": 3, "premium": 12, "solids": 5, "system": 7, @@ -1717,6 +1717,20 @@ "readOnlyHint": false } }, + { + "name": "pid_line_draw", + "group": "pid", + "cost": "mutate", + "summary": "Draw a P&ID line port-to-port: orthogonal route, ISA-5.1 class, line number, markers, arrow.", + "tags": [ + "create", + "pid" + ], + "annotations": { + "title": "P&ID: Draw Line", + "readOnlyHint": false + } + }, { "name": "pid_symbol_insert", "group": "pid", diff --git a/engineering/pid/drawlines.py b/engineering/pid/drawlines.py new file mode 100644 index 0000000..5a055c5 --- /dev/null +++ b/engineering/pid/drawlines.py @@ -0,0 +1,270 @@ +"""``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"} +_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"] + 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)), + float(info.properties.get("x_scale", 1.0)), + ) + 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 == "LINE": + 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", [])] + + +async def existing_pid_lines(backend: AutoCADBackend) -> list[dict]: + """Every LINE/LWPOLYLINE on a P&ID line layer, with its payload when it has one.""" + out = [] + for type_filter in ("LWPOLYLINE", "LINE"): + offset = 0 + while True: + page = await backend.entity_list(type_filter=type_filter, limit=_PAGE, offset=offset) + for info in page: + if info.layer.upper() not in PID_LINE_LAYERS: + continue + payload = ( + await read_payload(backend, info.handle) if info.type == "LWPOLYLINE" 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, + } diff --git a/server.py b/server.py index 2dbecb6..28c696b 100644 --- a/server.py +++ b/server.py @@ -6357,7 +6357,7 @@ async def solid_boolean( # --------------------------------------------------------------------------- -# ── SECTION 17: P&ID (2 tools) ────────────────────────────────────────────── +# ── SECTION 17: P&ID (3 tools) ────────────────────────────────────────────── # --------------------------------------------------------------------------- @@ -6463,6 +6463,77 @@ async def pid_symbol_insert( ) +@cad_tool( + summary=( + "Draw a P&ID line port-to-port: orthogonal route, ISA-5.1 class, " + "line number, markers, arrow." + ), + cost="mutate", +) +@mcp.tool( + annotations={"title": "P&ID: Draw Line", "readOnlyHint": False}, + tags={"pid", "create"}, +) +async def pid_line_draw( + from_: Annotated[dict, "{handle, port} of a placed symbol, or {x, y}"], + to: Annotated[dict, "{handle, port} of a placed symbol, or {x, y}"], + line_class: Annotated[ + str, + "process_major | process_minor | utility | pneumatic | electric | hydraulic | capillary | data", + ] = "process_major", + route: Annotated[str | list, "auto (orthogonal) | direct | [[x, y], ...] waypoints"] = "auto", + stub: Annotated[ + float, + Field(default=5.0, ge=0, description="Minimum straight run leaving each port (mm)"), + ] = 5.0, + line_number: Annotated[ + str | None, "Verbatim line number; omit to build one from the fields" + ] = None, + size: Annotated[str | None, "Nominal size, e.g. 100"] = None, + service: Annotated[str | None, "Service code, e.g. P"] = None, + spec: Annotated[str | None, "Piping spec, e.g. CS1"] = None, + insulation: Annotated[str | None, "Insulation code, e.g. IH"] = None, + number_format: Annotated[ + str | None, + "Default {size}-{service}-{seq}-{spec}-{insulation}; empty fields drop out", + ] = None, + label: Annotated[bool, "Write the line number along the longest segment"] = True, + arrow: Annotated[ + bool | None, "Flow arrow at the end; default per class (process yes, signal no)" + ] = None, + ctx: Context = None, +) -> dict: + """Connect two ports with one LWPOLYLINE on the class's layer. + + Ports come from `pid_symbol_insert`; a bubble's radial port is entered + without a name. `auto` picks the shortest orthogonal route that leaves each + port straight for `stub` mm; when none exists the call refuses and says so + — pass waypoints. `crossings` counts existing P&ID lines the new one cuts + (reported, never refused) and `port_reuse` names ports that already had a + line (branching is legal). Signal classes get ISA-5.1 markers (`//`, `X`, + `L`, `o`) as small blocks on segments >= 15 mm; electric is dashed by layer. + """ + from engineering.pid.drawlines import draw_line + + await ctx.info(f"P&ID line {line_class}") + return await draw_line( + _backend(ctx), + from_, + to, + line_class, + route, + stub, + line_number, + size, + service, + spec, + insulation, + number_format, + label, + arrow, + ) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_pid_line_draw.py b/tests/test_pid_line_draw.py new file mode 100644 index 0000000..f3aaf3e --- /dev/null +++ b/tests/test_pid_line_draw.py @@ -0,0 +1,140 @@ +"""pid_line_draw: port-to-port orthogonal lines with class, number, markers, arrows.""" + +from __future__ import annotations + +import pytest + +from engineering.pid.drawlines import draw_line +from engineering.pid.insert import place_symbol +from engineering.pid.xdata import read_payload + +pytestmark = pytest.mark.asyncio + + +async def _pump_and_vessel(backend): + pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-101") + vessel = await place_symbol(backend, "vertical_vessel", 220, 120, tag="V-201") + return pump, vessel + + +async def test_process_line_between_ports(backend): + pump, vessel = await _pump_and_vessel(backend) + result = await draw_line( + backend, + {"handle": pump["handle"], "port": "discharge"}, + {"handle": vessel["handle"], "port": "N3"}, + line_class="process_major", + size="100", + service="P", + spec="CS1", + ) + assert result["layer"] == "PROCESS-PIPING-MAIN" and result["line_class"] == "process_major" + assert result["vertices"][0] == [100.0, 106.0] and result["vertices"][-1] == [207.0, 128.0] + assert result["line_number"] == "100-P-1-CS1" and result["label_handle"] + assert result["arrow_handle"] and result["marker_handles"] == [] + assert result["crossings"] == 0 and result["port_reuse"] == [] + info = await backend.entity_get(result["handle"]) + assert info.type == "LWPOLYLINE" and info.layer == "PROCESS-PIPING-MAIN" + payload = await read_payload(backend, result["handle"]) + assert payload["kind"] == "line" and payload["from"] == { + "handle": pump["handle"], + "port": "discharge", + } + assert payload["seq"] == 1 + label = await backend.entity_get(result["label_handle"]) + assert label.layer == "PROCESS-LINE-TEXT" and label.properties["text"] == "100-P-1-CS1" + arrow = await backend.entity_get(result["arrow_handle"]) + assert arrow.properties["block_name"] == "PID_MARKER_ARROW_FLOW" + assert arrow.layer == "PROCESS-PIPING-MAIN" + + +async def test_sequence_increments_and_number_can_be_given(backend): + pump, vessel = await _pump_and_vessel(backend) + first = await draw_line( + backend, + {"handle": pump["handle"], "port": "discharge"}, + {"handle": vessel["handle"], "port": "N3"}, + size="100", + service="P", + ) + second = await draw_line( + backend, + {"handle": vessel["handle"], "port": "N4"}, + {"x": 300, "y": 128}, + size="80", + service="P", + ) + assert (first["line_number"], second["line_number"]) == ("100-P-1", "80-P-2") + third = await draw_line( + backend, + {"handle": vessel["handle"], "port": "N1"}, + {"x": 210, "y": 200}, + line_number="VENT-01", + ) + assert third["line_number"] == "VENT-01" + + +async def test_signal_line_from_a_bubble_gets_markers_and_no_arrow(backend): + cv = await place_symbol(backend, "globe", 100, 100, tag="FCV-1", actuator="diaphragm") + fic = await place_symbol( + backend, "instrument", 100, 160, tag="FIC-1", type="dcs", location="primary" + ) + result = await draw_line( + backend, + {"handle": fic["handle"]}, + {"handle": cv["handle"], "port": "signal"}, + line_class="pneumatic", + ) + assert result["vertices"][0] == [100.0, 155.0], "the line starts on the bubble's circle" + assert result["vertices"][-1] == [100.0, 109.0] + assert result["arrow_handle"] is None and len(result["marker_handles"]) == 1 + mark = await backend.entity_get(result["marker_handles"][0]) + assert mark.properties["block_name"] == "PID_MARKER_MARK_PNEUMATIC" + assert mark.layer == "INSTRUMENT-LINE-SIGNAL" + line = await backend.entity_get(result["handle"]) + assert line.linetype == "Continuous" + electric = await draw_line( + backend, {"handle": fic["handle"]}, {"x": 160, "y": 160}, line_class="electric" + ) + assert (await backend.entity_get(electric["handle"])).linetype == "ByLayer" + + +async def test_port_reuse_and_crossings_are_reported_not_refused(backend): + pump, vessel = await _pump_and_vessel(backend) + await draw_line( + backend, + {"handle": pump["handle"], "port": "discharge"}, + {"handle": vessel["handle"], "port": "N3"}, + ) + again = await draw_line( + backend, {"handle": pump["handle"], "port": "discharge"}, {"x": 100, "y": 200} + ) + assert again["port_reuse"] == [{"handle": pump["handle"], "port": "discharge"}] + crossing = await draw_line( + backend, {"x": 150, "y": 90}, {"x": 150, "y": 140}, line_class="utility" + ) + assert crossing["crossings"] == 1 + + +async def test_refusals(backend): + pump, vessel = await _pump_and_vessel(backend) + with pytest.raises(ValueError, match="same"): + await draw_line( + backend, + {"handle": pump["handle"], "port": "discharge"}, + {"handle": pump["handle"], "port": "discharge"}, + ) + with pytest.raises(ValueError, match="discharge, suction"): # payload ports are JSON-sorted + await draw_line(backend, {"handle": pump["handle"], "port": "outlet"}, {"x": 0, "y": 0}) + with pytest.raises(ValueError, match="choose a port"): + await draw_line(backend, {"handle": pump["handle"]}, {"x": 0, "y": 0}) + with pytest.raises(ValueError, match="line_class"): + await draw_line(backend, {"x": 0, "y": 0}, {"x": 10, "y": 0}, line_class="steam") + first = await draw_line( + backend, {"handle": pump["handle"], "port": "discharge"}, {"x": 100, "y": 200} + ) + with pytest.raises(ValueError, match="decoration"): + await draw_line(backend, {"handle": first["arrow_handle"], "port": "in"}, {"x": 0, "y": 0}) + with pytest.raises(ValueError, match="not a P&ID symbol"): + line = await backend.entity_create_line(0, 0, 1, 1) + await draw_line(backend, {"handle": line.handle, "port": "in"}, {"x": 0, "y": 0}) diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index f471df5..9809281 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (159 tools, 20 groups). + """Frozen snapshot of the surface (160 tools, 20 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and @@ -509,10 +509,10 @@ async def test_tool_group_sizes_are_unchanged(): `entity_set_xdata` joined SECTION 5 (the getter is tagged `query`, the setter `modify`, so the pair splits across two groups). `pid` appeared (0 -> 2) when v1.6's SECTION 17 opened with `pid_symbol_list` / - `pid_symbol_insert`; the `pid` tag is ranked first in - `_GROUP_TAG_PRIORITY` so those tools file under `pid` rather than under - their secondary `query` / `create` tags. Every other number here has been - unchanged since the snapshot was taken. + `pid_symbol_insert`, then 2 -> 3 when `pid_line_draw` joined; the `pid` + tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under + `pid` rather than under their secondary `query` / `create` tags. Every + other number here has been unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -528,7 +528,7 @@ async def test_tool_group_sizes_are_unchanged(): "entity_query": 9, "layers": 14, "layouts": 12, - "pid": 2, + "pid": 3, "premium": 12, "solids": 5, "system": 7, @@ -537,4 +537,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 159 + assert sum(sizes.values()) == 160