fix(task-13): mirrored-symbol ports and COM-visible existing lines
Two review findings on pid_line_draw, both root-caused:
1. existing_pid_lines() filtered entity_list(type_filter="LWPOLYLINE") and
tested info.type == "LWPOLYLINE". A live LWPOLYLINE's ActiveX ObjectName
is AcDbPolyline (the repo's own 2026-08-06 measurement), which the COM
engine reports as POLYLINE, so on live AutoCAD every auto-built number was
seq 1, crossings always 0 and port_reuse always []. The reader now lists
without a type filter and accepts {LWPOLYLINE, POLYLINE, 2DPOLYLINE} plus
LINE — the same set the Task 14 graph reader keeps. The COM _entity_info
polyline branch matched only the DXF-flavoured "AcDbLWPolyline", so a live
LWPOLYLINE never got `points` either; it now accepts both spellings (with
the OCS normalisation), which is what makes the crossing count real there.
2. resolve_endpoint() applied only x_scale, so a mirrored INSERT
(entity_mirror writes y_scale = -1) resolved its port to a point off the
symbol and the line was drawn through the pump body. transform_port takes
an optional y_scale (default: uniform, signature unchanged) and applies the
full reflect-then-rotate transform to point, direction and radius; a
stretched symbol (|x_scale| != |y_scale|) has no circle for a radial port
and is refused with a clear ValueError instead of a silent wrong number.
Tests: mirror about both axes resolves onto the real geometry, a stretched
copy is refused, and a fake-COM AcDbPolyline probe sees the existing line
with its seq, vertices and from-ref.
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
e8f1a666f8
commit
e924b5db99
+127
-2
@@ -2,11 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.pid.drawlines import draw_line
|
||||
from backends.com_backend import ComBackend
|
||||
from engineering.pid.drawlines import draw_line, existing_pid_lines, resolve_endpoint
|
||||
from engineering.pid.insert import place_symbol
|
||||
from engineering.pid.xdata import read_payload
|
||||
from engineering.pid.xdata import (
|
||||
APP_NAME,
|
||||
encode_payload,
|
||||
line_payload,
|
||||
read_payload,
|
||||
write_payload,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
@@ -138,3 +147,119 @@ async def test_refusals(backend):
|
||||
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})
|
||||
|
||||
|
||||
async def test_mirrored_symbol_ports_follow_the_mirrored_geometry(backend):
|
||||
"""``entity_mirror`` on an INSERT writes ``y_scale = -1``; the port has to be
|
||||
on the mirrored pump (discharge stub at (200, 104)->(200, 106)), not at the
|
||||
unmirrored offset — a line attached at y=94 runs through the pump body."""
|
||||
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-1")
|
||||
about_vertical = await backend.entity_mirror(pump["handle"], 150, 0, 150, 200)
|
||||
info = await backend.entity_get(about_vertical.handle)
|
||||
assert info.properties["y_scale"] == -1.0, "the fixture must exercise a real mirror"
|
||||
ep = await resolve_endpoint(backend, {"handle": about_vertical.handle, "port": "discharge"})
|
||||
assert (ep["x"], ep["y"], ep["direction_deg"]) == (200.0, 106.0, 90.0)
|
||||
suction = await resolve_endpoint(backend, {"handle": about_vertical.handle, "port": "suction"})
|
||||
assert (suction["x"], suction["y"], suction["direction_deg"]) == (204.0, 100.0, 0.0)
|
||||
result = await draw_line(
|
||||
backend, {"handle": about_vertical.handle, "port": "discharge"}, {"x": 200, "y": 160}
|
||||
)
|
||||
assert result["vertices"][0] == [200.0, 106.0]
|
||||
|
||||
about_horizontal = await backend.entity_mirror(pump["handle"], 0, 150, 300, 150)
|
||||
ep = await resolve_endpoint(backend, {"handle": about_horizontal.handle, "port": "discharge"})
|
||||
assert (ep["x"], ep["y"], ep["direction_deg"]) == (100.0, 194.0, 270.0)
|
||||
|
||||
|
||||
async def test_stretched_symbol_is_refused_not_resolved_off_the_geometry(backend):
|
||||
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-1")
|
||||
payload = await read_payload(backend, pump["handle"])
|
||||
stretched = await backend.block_insert(pump["block_name"], 300, 100, 2.0, 1.0, 0.0)
|
||||
await write_payload(backend, stretched.handle, payload)
|
||||
with pytest.raises(ValueError, match="non-uniform"):
|
||||
await resolve_endpoint(backend, {"handle": stretched.handle, "port": "discharge"})
|
||||
with pytest.raises(ValueError, match="non-uniform"):
|
||||
await draw_line(
|
||||
backend, {"handle": stretched.handle, "port": "discharge"}, {"x": 0, "y": 0}
|
||||
)
|
||||
|
||||
|
||||
# ── the COM engine names a lightweight polyline ``AcDbPolyline`` ─────────────
|
||||
|
||||
|
||||
class _ComEntity:
|
||||
"""An ActiveX entity with only the members it is given."""
|
||||
|
||||
def __init__(self, object_name, **members):
|
||||
self.ObjectName = object_name
|
||||
for name, value in members.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
def _com_pid_polyline(handle, coords, payload):
|
||||
tags = encode_payload(payload)
|
||||
codes = [1001] + [1000] * len(tags)
|
||||
values = [APP_NAME] + tags
|
||||
return _ComEntity(
|
||||
"AcDbPolyline",
|
||||
Handle=handle,
|
||||
Layer="PROCESS-PIPING-MAIN",
|
||||
Color=256,
|
||||
Linetype="ByLayer",
|
||||
Visible=True,
|
||||
Closed=False,
|
||||
Length=1.0,
|
||||
Coordinates=tuple(coords),
|
||||
Normal=(0.0, 0.0, 1.0),
|
||||
Elevation=0.0,
|
||||
GetBoundingBox=lambda: ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0)),
|
||||
GetXData=lambda app: (codes, values),
|
||||
)
|
||||
|
||||
|
||||
def _fake_com(monkeypatch, entities):
|
||||
backend = ComBackend()
|
||||
|
||||
async def run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", run_inline)
|
||||
by_handle = {ent.Handle: ent for ent in entities}
|
||||
space = SimpleNamespace(Count=len(entities), Item=lambda i: entities[i])
|
||||
doc = SimpleNamespace(HandleToObject=lambda handle: by_handle[handle])
|
||||
monkeypatch.setattr("backends.com_backend._msp", lambda: space)
|
||||
monkeypatch.setattr("backends.com_backend._acad_doc", lambda: doc)
|
||||
return backend
|
||||
|
||||
|
||||
async def test_existing_lines_are_seen_on_the_com_engine(monkeypatch):
|
||||
"""A live LWPOLYLINE's ObjectName is ``AcDbPolyline`` (measured on AutoCAD
|
||||
2026), which the COM engine reports as type POLYLINE. A reader that filters
|
||||
on LWPOLYLINE sees nothing there: every number restarts at seq 1, crossings
|
||||
stay 0 and port reuse is never reported."""
|
||||
payload = line_payload(
|
||||
"process_major",
|
||||
"100-P-7",
|
||||
"100",
|
||||
"P",
|
||||
None,
|
||||
None,
|
||||
7,
|
||||
{"handle": "2A", "port": "discharge"},
|
||||
None,
|
||||
)
|
||||
poly = _com_pid_polyline("3E", (100.0, 106.0, 100.0, 128.0, 207.0, 128.0), payload)
|
||||
other_layer = _com_pid_polyline("3F", (0.0, 0.0, 1.0, 1.0), payload)
|
||||
other_layer.Layer = "GEOMETRY"
|
||||
backend = _fake_com(monkeypatch, [poly, other_layer])
|
||||
|
||||
assert (await backend.entity_get("3E")).type == "POLYLINE"
|
||||
lines = await existing_pid_lines(backend)
|
||||
|
||||
assert [ln["handle"] for ln in lines] == ["3E"]
|
||||
assert lines[0]["vertices"] == [(100.0, 106.0), (100.0, 128.0), (207.0, 128.0)]
|
||||
assert lines[0]["payload"]["seq"] == 7
|
||||
assert lines[0]["payload"]["from"] == {"handle": "2A", "port": "discharge"}
|
||||
|
||||
@@ -194,6 +194,21 @@ def test_transform_port_applies_rotation_and_scale_about_the_insertion():
|
||||
assert radial["direction_deg"] is None and radial["radius"] == pytest.approx(15.0)
|
||||
|
||||
|
||||
def test_transform_port_applies_a_mirror_and_refuses_a_stretch():
|
||||
port = Port("out", 0.0, 6.0, 90.0, "process")
|
||||
flipped_y = transform_port(port, 100.0, 200.0, 0.0, 1.0, -1.0)
|
||||
assert (flipped_y["x"], flipped_y["y"], flipped_y["direction_deg"]) == (100.0, 194.0, 270.0)
|
||||
mirrored = transform_port(port, 200.0, 100.0, 180.0, 1.0, -1.0) # entity_mirror about x=150
|
||||
assert (mirrored["x"], mirrored["y"]) == pytest.approx((200.0, 106.0))
|
||||
assert mirrored["direction_deg"] == pytest.approx(90.0)
|
||||
flipped_x = transform_port(Port("in", -4.0, 0.0, 180.0, "process"), 0.0, 0.0, 0.0, -1.0, 1.0)
|
||||
assert (flipped_x["x"], flipped_x["y"], flipped_x["direction_deg"]) == (4.0, 0.0, 0.0)
|
||||
radial = transform_port(Port("signal", 0.0, 0.0, None, "signal", 5.0), 0.0, 0.0, 0.0, -2.0)
|
||||
assert radial["radius"] == pytest.approx(10.0)
|
||||
with pytest.raises(ValueError, match="non-uniform"):
|
||||
transform_port(port, 0.0, 0.0, 0.0, 2.0, 1.0)
|
||||
|
||||
|
||||
def test_list_symbols_reports_variants_and_ports():
|
||||
rows = {row["symbol"]: row for row in list_symbols()}
|
||||
assert rows["instrument"]["variants"] == {
|
||||
|
||||
Reference in New Issue
Block a user