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
@@ -27,6 +27,12 @@ if TYPE_CHECKING:
|
||||
TEXT_HEIGHT = 2.5
|
||||
LABEL_LAYER = "PROCESS-LINE-TEXT"
|
||||
_INSERT_TYPES = {"INSERT", "BLOCKREFERENCE"}
|
||||
# ezdxf reports a lightweight polyline as LWPOLYLINE; the COM engine derives the
|
||||
# type from the ActiveX ObjectName, and a live LWPOLYLINE is ``AcDbPolyline``
|
||||
# (measured on AutoCAD 2026 — see backends/com_backend.py), so it arrives as
|
||||
# POLYLINE, with the heavy kind as 2DPOLYLINE. Same set the graph reader keeps.
|
||||
_POLY_TYPES = {"LWPOLYLINE", "POLYLINE", "2DPOLYLINE"}
|
||||
_LINE_TYPES = {"LINE"}
|
||||
_PAGE = 1000
|
||||
|
||||
|
||||
@@ -57,13 +63,21 @@ async def resolve_endpoint(backend: AutoCADBackend, ref: dict) -> dict:
|
||||
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)),
|
||||
)
|
||||
x_scale = float(info.properties.get("x_scale", 1.0))
|
||||
# The full INSERT transform: a mirrored symbol (``entity_mirror`` writes
|
||||
# ``y_scale = -1``) has its ports on the mirrored geometry, not on the
|
||||
# unmirrored one; a stretched symbol is refused by ``transform_port``.
|
||||
try:
|
||||
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)),
|
||||
x_scale,
|
||||
float(info.properties.get("y_scale", x_scale)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{handle}: {exc}") from exc
|
||||
return {**world, "handle": handle, "port": port_name, "block_name": block_name}
|
||||
if "x" in ref and "y" in ref:
|
||||
return {
|
||||
@@ -82,29 +96,34 @@ async def resolve_endpoint(backend: AutoCADBackend, ref: dict) -> dict:
|
||||
|
||||
|
||||
def _vertices(info) -> list[tuple[float, float]]:
|
||||
if info.type == "LINE":
|
||||
if info.type in _LINE_TYPES:
|
||||
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", [])]
|
||||
return [(float(p[0]), float(p[1])) for p in info.properties.get("points") or []]
|
||||
|
||||
|
||||
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."""
|
||||
"""Every LINE/polyline on a P&ID line layer, with its payload when it has one.
|
||||
|
||||
Listed without a type filter: the two engines name a lightweight polyline
|
||||
differently (``_POLY_TYPES``), and a filter spelled for one of them is blind
|
||||
on the other — every auto-built number would restart at 1 and no crossing
|
||||
or port reuse would ever be reported there.
|
||||
"""
|
||||
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
|
||||
offset = 0
|
||||
while True:
|
||||
page = await backend.entity_list(limit=_PAGE, offset=offset)
|
||||
for info in page:
|
||||
if info.type not in _POLY_TYPES | _LINE_TYPES:
|
||||
continue
|
||||
if info.layer.upper() not in PID_LINE_LAYERS:
|
||||
continue
|
||||
payload = await read_payload(backend, info.handle) if info.type in _POLY_TYPES else None
|
||||
out.append({"handle": info.handle, "vertices": _vertices(info), "payload": payload})
|
||||
if len(page) < _PAGE:
|
||||
break
|
||||
offset += _PAGE
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -176,21 +176,55 @@ def all_specs() -> list[SymbolSpec]:
|
||||
return specs
|
||||
|
||||
|
||||
def transform_port(port: Port, x: float, y: float, rotation_deg: float, scale: float) -> dict:
|
||||
"""Symbol-local port -> WCS, for an INSERT at (x, y) rotated by ``rotation_deg``
|
||||
and uniformly scaled."""
|
||||
def transform_port(
|
||||
port: Port,
|
||||
x: float,
|
||||
y: float,
|
||||
rotation_deg: float,
|
||||
scale: float,
|
||||
y_scale: float | None = None,
|
||||
) -> dict:
|
||||
"""Symbol-local port -> WCS, for an INSERT at (x, y) rotated by ``rotation_deg``.
|
||||
|
||||
``scale`` is the X factor; ``y_scale`` defaults to it (a uniform INSERT). A
|
||||
negative factor is a mirror — ``entity_mirror`` on an INSERT writes
|
||||
``y_scale = -1`` — and is applied in full, to the point, the direction and
|
||||
the radius, so the port lands where the mirrored geometry actually is. The
|
||||
magnitudes must match: a stretched symbol has no circle for a radial port to
|
||||
sit on, so it is refused instead of resolving to a point off the symbol.
|
||||
"""
|
||||
sx = float(scale)
|
||||
sy = sx if y_scale is None else float(y_scale)
|
||||
if abs(abs(sx) - abs(sy)) > 1e-9:
|
||||
raise ValueError(
|
||||
f"non-uniform INSERT scale (x_scale={sx:g}, y_scale={sy:g}): P&ID symbols "
|
||||
"are placed with one scale factor (a mirror may flip its sign); re-insert "
|
||||
"the symbol with pid_symbol_insert"
|
||||
)
|
||||
a = math.radians(rotation_deg)
|
||||
lx, ly = port.x * scale, port.y * scale
|
||||
wx = x + lx * math.cos(a) - ly * math.sin(a)
|
||||
wy = y + lx * math.sin(a) + ly * math.cos(a)
|
||||
direction = None if port.direction_deg is None else (port.direction_deg + rotation_deg) % 360.0
|
||||
cos_a, sin_a = math.cos(a), math.sin(a)
|
||||
lx, ly = port.x * sx, port.y * sy
|
||||
wx = x + lx * cos_a - ly * sin_a
|
||||
wy = y + lx * sin_a + ly * cos_a
|
||||
if port.direction_deg is None:
|
||||
direction = None
|
||||
else:
|
||||
# Reflect first (a flipped X negates the angle about 90°, a flipped Y
|
||||
# negates it about 0°), then rotate — exact, so the uniform case keeps
|
||||
# the same numbers it always had.
|
||||
d = float(port.direction_deg)
|
||||
if sx < 0:
|
||||
d = 180.0 - d
|
||||
if sy < 0:
|
||||
d = -d
|
||||
direction = (d + rotation_deg) % 360.0
|
||||
return {
|
||||
"name": port.name,
|
||||
"x": wx,
|
||||
"y": wy,
|
||||
"direction_deg": direction,
|
||||
"kind": port.kind,
|
||||
"radius": port.radius * scale,
|
||||
"radius": port.radius * abs(sx),
|
||||
"inferred": False,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user