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:
Umut CAN
2026-09-15 23:48:35 +03:00
co-authored by Claude Opus 5
parent e8f1a666f8
commit e924b5db99
5 changed files with 243 additions and 40 deletions
+42 -8
View File
@@ -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,
}