feat(pid): pid_line_draw — port-to-port orthogonal lines with class, number, markers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
4b6abf7b92
commit
e8f1a666f8
@@ -19,7 +19,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **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`,
|
> 154 is the **registered** count; a default install advertises 149 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class ToolAliases:
|
|||||||
# BLOCK -- one command makes a definition either from selected objects
|
# BLOCK -- one command makes a definition either from selected objects
|
||||||
# (block_create_from_entities) or from scratch with attribute
|
# (block_create_from_entities) or from scratch with attribute
|
||||||
# definitions (block_define); a drafter typing it could mean either.
|
# 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.
|
# Every other AutoCAD command must map to exactly one tool.
|
||||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||||
@@ -80,6 +83,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
|||||||
"LAYER",
|
"LAYER",
|
||||||
"LAYOUT",
|
"LAYOUT",
|
||||||
"MEASUREGEOM",
|
"MEASUREGEOM",
|
||||||
|
"PLINE",
|
||||||
"QSELECT",
|
"QSELECT",
|
||||||
"SETVAR",
|
"SETVAR",
|
||||||
"ZOOM",
|
"ZOOM",
|
||||||
@@ -1367,6 +1371,19 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"process equipment symbol",
|
"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",
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 159,
|
"tools": 160,
|
||||||
"resources": 6,
|
"resources": 6,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 20
|
"groups": 20
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 12,
|
||||||
"pid": 2,
|
"pid": 3,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
"system": 7,
|
"system": 7,
|
||||||
@@ -1717,6 +1717,20 @@
|
|||||||
"readOnlyHint": false
|
"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",
|
"name": "pid_symbol_insert",
|
||||||
"group": "pid",
|
"group": "pid",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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():
|
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
|
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
|
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
|
`entity_set_xdata` joined SECTION 5 (the getter is tagged `query`, the
|
||||||
setter `modify`, so the pair splits across two groups). `pid` appeared
|
setter `modify`, so the pair splits across two groups). `pid` appeared
|
||||||
(0 -> 2) when v1.6's SECTION 17 opened with `pid_symbol_list` /
|
(0 -> 2) when v1.6's SECTION 17 opened with `pid_symbol_list` /
|
||||||
`pid_symbol_insert`; the `pid` tag is ranked first in
|
`pid_symbol_insert`, then 2 -> 3 when `pid_line_draw` joined; the `pid`
|
||||||
`_GROUP_TAG_PRIORITY` so those tools file under `pid` rather than under
|
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
||||||
their secondary `query` / `create` tags. Every other number here has been
|
`pid` rather than under their secondary `query` / `create` tags. Every
|
||||||
unchanged since the snapshot was taken.
|
other number here has been unchanged since the snapshot was taken.
|
||||||
"""
|
"""
|
||||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
@@ -528,7 +528,7 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 12,
|
||||||
"pid": 2,
|
"pid": 3,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
"system": 7,
|
"system": 7,
|
||||||
@@ -537,4 +537,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 159
|
assert sum(sizes.values()) == 160
|
||||||
|
|||||||
Reference in New Issue
Block a user