fix(task-22): ucs_list decides "world" from the frame, not the empty name — an unnamed UCS is its own current row
An unnamed UCS (UCS Origin / 3P without saving, the common kind) has an empty UCSNAME exactly like WCS does, so both engines answered `current: world` while the drawing was not in WCS — verified live on AutoCAD 2026 (`_.UCS _O 10,10,0` → UCSNAME="" WORLDUCS=0) and on the DXF it writes ($UCSNAME='' $UCSORG=(0,20,0)). A silent wrong value. - COM: a named match still wins; otherwise WORLDUCS (AutoCAD's own answer) decides, and WORLDUCS=0 appends a `name: None` row built from UCSORG/UCSXDIR/UCSYDIR. - ezdxf: the same rule against $UCSORG/$UCSXDIR/$UCSYDIR via the new `engineering.environment.ucs.is_world_axes`; a stale $UCSNAME falls back to the frame instead of leaving no row current. - Server `ucs_list` result: `current` is null for an unnamed UCS (never `world`), plus `current_unnamed: bool` so null is unambiguous. - Tests: helper, headless (unnamed row, stale name, and the exact AutoCAD DXF over the wire), and COM fake reading WORLDUCS + the frame variables. 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
4bf686e943
commit
64cec37cc7
+23
-5
@@ -1733,7 +1733,7 @@ class ComBackend(AutoCADBackend):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def ucs_list(self) -> list[dict]:
|
async def ucs_list(self) -> list[dict]:
|
||||||
from engineering.environment.ucs import WORLD
|
from engineering.environment.ucs import WORLD, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS
|
||||||
|
|
||||||
def _sync():
|
def _sync():
|
||||||
app = _acad_app()
|
app = _acad_app()
|
||||||
@@ -1742,15 +1742,33 @@ class ComBackend(AutoCADBackend):
|
|||||||
rows = [
|
rows = [
|
||||||
{
|
{
|
||||||
"name": WORLD,
|
"name": WORLD,
|
||||||
"origin": [0.0, 0.0, 0.0],
|
"origin": list(WORLD_ORIGIN),
|
||||||
"x_axis": [1.0, 0.0, 0.0],
|
"x_axis": list(WORLD_X_AXIS),
|
||||||
"y_axis": [0.0, 1.0, 0.0],
|
"y_axis": list(WORLD_Y_AXIS),
|
||||||
"current": current == "",
|
"current": False,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
collection = doc.UserCoordinateSystems
|
collection = doc.UserCoordinateSystems
|
||||||
for index in range(int(collection.Count)):
|
for index in range(int(collection.Count)):
|
||||||
rows.append(self._com_ucs_row(collection.Item(index), current))
|
rows.append(self._com_ucs_row(collection.Item(index), current))
|
||||||
|
if any(row["current"] for row in rows[1:]):
|
||||||
|
return rows
|
||||||
|
# No saved entry is current. UCSNAME is empty for an unnamed UCS
|
||||||
|
# (UCS Origin / 3P without saving) as well as for WCS — verified
|
||||||
|
# live (AutoCAD 2026: after `_.UCS _O 10,10,0`, UCSNAME="" and
|
||||||
|
# WORLDUCS=0). WORLDUCS is AutoCAD's own answer to "is this WCS".
|
||||||
|
if int(app.GetVariable("WORLDUCS")) == 1:
|
||||||
|
rows[0]["current"] = True
|
||||||
|
else:
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": None,
|
||||||
|
"origin": [float(c) for c in app.GetVariable("UCSORG")],
|
||||||
|
"x_axis": [float(c) for c in app.GetVariable("UCSXDIR")],
|
||||||
|
"y_axis": [float(c) for c in app.GetVariable("UCSYDIR")],
|
||||||
|
"current": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
return await self._run(_sync)
|
return await self._run(_sync)
|
||||||
|
|||||||
@@ -86,7 +86,14 @@ class EnvironmentContract(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def ucs_list(self) -> list[dict]:
|
async def ucs_list(self) -> list[dict]:
|
||||||
"""→ ``[{"name", "origin", "x_axis", "y_axis", "current": bool}]`` + the implicit "world"."""
|
"""→ ``[{"name", "origin", "x_axis", "y_axis", "current": bool}]`` + the implicit "world".
|
||||||
|
|
||||||
|
Exactly one row is current. An *unnamed* current UCS (``UCS Origin`` /
|
||||||
|
``3P`` without saving) is a trailing row with ``name: None`` carrying
|
||||||
|
its frame — "world" is current only when the frame is the WCS
|
||||||
|
(WORLDUCS live, the ``$UCS*`` header headless), never because the
|
||||||
|
name is empty.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -2692,22 +2692,41 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def ucs_list(self) -> list[dict]:
|
async def ucs_list(self) -> list[dict]:
|
||||||
from engineering.environment.ucs import WORLD
|
from engineering.environment.ucs import (
|
||||||
|
WORLD,
|
||||||
|
WORLD_ORIGIN,
|
||||||
|
WORLD_X_AXIS,
|
||||||
|
WORLD_Y_AXIS,
|
||||||
|
is_world_axes,
|
||||||
|
)
|
||||||
|
|
||||||
def _sync():
|
def _sync():
|
||||||
doc = self._require_doc()
|
doc = self._require_doc()
|
||||||
current = str(doc.header.get("$UCSNAME", "") or "")
|
header = doc.header
|
||||||
rows = [self._ucs_row(WORLD, (0, 0, 0), (1, 0, 0), (0, 1, 0), current == "")]
|
current = str(header.get("$UCSNAME", "") or "")
|
||||||
|
origin = tuple(header.get("$UCSORG", WORLD_ORIGIN))
|
||||||
|
x_axis = tuple(header.get("$UCSXDIR", WORLD_X_AXIS))
|
||||||
|
y_axis = tuple(header.get("$UCSYDIR", WORLD_Y_AXIS))
|
||||||
|
rows = [self._ucs_row(WORLD, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS, False)]
|
||||||
|
named_current = False
|
||||||
for ucs in doc.ucs:
|
for ucs in doc.ucs:
|
||||||
|
is_current = current != "" and ucs.dxf.name.lower() == current.lower()
|
||||||
|
named_current = named_current or is_current
|
||||||
rows.append(
|
rows.append(
|
||||||
self._ucs_row(
|
self._ucs_row(
|
||||||
ucs.dxf.name,
|
ucs.dxf.name, ucs.dxf.origin, ucs.dxf.xaxis, ucs.dxf.yaxis, is_current
|
||||||
ucs.dxf.origin,
|
|
||||||
ucs.dxf.xaxis,
|
|
||||||
ucs.dxf.yaxis,
|
|
||||||
current != "" and ucs.dxf.name.lower() == current.lower(),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if named_current:
|
||||||
|
return rows
|
||||||
|
# No saved entry is current. An empty $UCSNAME is NOT "world": an
|
||||||
|
# unnamed UCS (UCS Origin / 3P without saving) has an empty name
|
||||||
|
# too, and AutoCAD writes exactly that. The frame decides, the way
|
||||||
|
# WORLDUCS does live.
|
||||||
|
if is_world_axes(origin, x_axis, y_axis):
|
||||||
|
rows[0]["current"] = True
|
||||||
|
else:
|
||||||
|
rows.append(self._ucs_row(None, origin, x_axis, y_axis, True))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
return await self._async(_sync)
|
return await self._async(_sync)
|
||||||
|
|||||||
@@ -12,6 +12,34 @@ import math
|
|||||||
WORLD = "world"
|
WORLD = "world"
|
||||||
ORTHO_TOLERANCE_DEG = 1e-3
|
ORTHO_TOLERANCE_DEG = 1e-3
|
||||||
|
|
||||||
|
#: The WCS frame — what ``$UCSORG`` / ``$UCSXDIR`` / ``$UCSYDIR`` (and the
|
||||||
|
#: UCSORG / UCSXDIR / UCSYDIR system variables) hold while no UCS is active.
|
||||||
|
WORLD_ORIGIN = (0.0, 0.0, 0.0)
|
||||||
|
WORLD_X_AXIS = (1.0, 0.0, 0.0)
|
||||||
|
WORLD_Y_AXIS = (0.0, 1.0, 0.0)
|
||||||
|
WORLD_TOLERANCE = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def is_world_axes(origin, x_axis, y_axis, *, tol: float = WORLD_TOLERANCE) -> bool:
|
||||||
|
"""True when the frame *is* the WCS — the test ``$UCSNAME == ""`` cannot make.
|
||||||
|
|
||||||
|
An unnamed UCS (``UCS Origin`` / ``UCS 3P`` without saving — the common
|
||||||
|
kind) also has an empty name, so "is the drawing in WCS" has to be
|
||||||
|
answered from the frame itself: AutoCAD's own answer is WORLDUCS, and
|
||||||
|
this is the same comparison for a header that has no such variable.
|
||||||
|
"""
|
||||||
|
for got, want in (
|
||||||
|
(origin, WORLD_ORIGIN),
|
||||||
|
(x_axis, WORLD_X_AXIS),
|
||||||
|
(y_axis, WORLD_Y_AXIS),
|
||||||
|
):
|
||||||
|
coords = tuple(float(c) for c in got)
|
||||||
|
if len(coords) != 3:
|
||||||
|
return False
|
||||||
|
if any(abs(a - b) > tol for a, b in zip(coords, want, strict=True)):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _vec3(value, where: str) -> tuple[float, float, float]:
|
def _vec3(value, where: str) -> tuple[float, float, float]:
|
||||||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||||
|
|||||||
@@ -7050,13 +7050,19 @@ async def view_named_list(ctx: Context = None) -> dict:
|
|||||||
)
|
)
|
||||||
async def ucs_list(ctx: Context = None) -> dict:
|
async def ucs_list(ctx: Context = None) -> dict:
|
||||||
"""The `world` row first, then every UCS table entry with origin and unit
|
"""The `world` row first, then every UCS table entry with origin and unit
|
||||||
axes, `current` on the active one. Tool coordinates stay WCS on both
|
axes, `current` on the active one. `world` is current only when the frame
|
||||||
engines whatever is current. No refusals. Pack: settings · lean: no."""
|
IS the WCS (WORLDUCS live, the `$UCS*` header headless) — an unnamed UCS
|
||||||
|
(`UCS Origin` / `3P` without saving) has an empty name too and is
|
||||||
|
reported as a trailing row with `name: null` and its origin/axes, so
|
||||||
|
`current` is `null` then and never `world`. Tool coordinates stay WCS on
|
||||||
|
both engines whatever is current. No refusals. Pack: settings · lean: no."""
|
||||||
rows = await _backend(ctx).ucs_list()
|
rows = await _backend(ctx).ucs_list()
|
||||||
|
active = next((r for r in rows if r["current"]), None)
|
||||||
return {
|
return {
|
||||||
"ucs": rows,
|
"ucs": rows,
|
||||||
"count": len(rows),
|
"count": len(rows),
|
||||||
"current": next((r["name"] for r in rows if r["current"]), None),
|
"current": active["name"] if active else None,
|
||||||
|
"current_unnamed": bool(active and active["name"] is None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+114
-2
@@ -12,8 +12,10 @@ from __future__ import annotations
|
|||||||
import types
|
import types
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastmcp import Client
|
||||||
|
|
||||||
from engineering.environment.ucs import resolve_ucs_axes
|
import server
|
||||||
|
from engineering.environment.ucs import is_world_axes, resolve_ucs_axes
|
||||||
from engineering.environment.views import resolve_view_args
|
from engineering.environment.views import resolve_view_args
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -53,6 +55,14 @@ def test_ucs_axes_are_normalised_and_orthogonality_is_measured():
|
|||||||
resolve_ucs_axes([0, 0], [1, 0, 0], [0, 1, 0])
|
resolve_ucs_axes([0, 0], [1, 0, 0], [0, 1, 0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_world_is_decided_by_the_frame_not_the_name():
|
||||||
|
assert is_world_axes((0, 0, 0), (1, 0, 0), (0, 1, 0))
|
||||||
|
assert is_world_axes([0.0, 0.0, 1e-12], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0])
|
||||||
|
assert not is_world_axes((0, 20, 0), (1, 0, 0), (0, 1, 0)) # UCS Origin
|
||||||
|
assert not is_world_axes((0, 0, 0), (0, 1, 0), (-1, 0, 0)) # rotated 90°
|
||||||
|
assert not is_world_axes((0, 0), (1, 0, 0), (0, 1, 0))
|
||||||
|
|
||||||
|
|
||||||
# ── headless: named views ───────────────────────────────────────────────────
|
# ── headless: named views ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -213,6 +223,74 @@ async def test_ucs_replace_keeps_one_entry(backend):
|
|||||||
assert len(list(backend._doc.ucs)) == 1
|
assert len(list(backend._doc.ucs)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ucs_list_does_not_call_an_unnamed_ucs_world(backend):
|
||||||
|
"""``UCS Origin`` / ``UCS 3P`` without saving — the common kind — leaves
|
||||||
|
``$UCSNAME`` empty exactly like WCS does (AutoCAD 2026 writes
|
||||||
|
``$UCSNAME=''``, ``$UCSORG=(0,20,0)`` after ``_.UCS _O``). The frame, not
|
||||||
|
the name, says whether the drawing is in WCS."""
|
||||||
|
await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
|
||||||
|
header = backend._doc.header
|
||||||
|
header["$UCSNAME"] = ""
|
||||||
|
header["$UCSORG"] = (50.0, 20.0, 0.0)
|
||||||
|
header["$UCSXDIR"] = (0.0, 1.0, 0.0)
|
||||||
|
header["$UCSYDIR"] = (-1.0, 0.0, 0.0)
|
||||||
|
rows = await backend.ucs_list()
|
||||||
|
assert [r["name"] for r in rows] == ["world", "FRONT", None]
|
||||||
|
assert [r["current"] for r in rows] == [False, False, True]
|
||||||
|
assert rows[2] == {
|
||||||
|
"name": None,
|
||||||
|
"origin": [50.0, 20.0, 0.0],
|
||||||
|
"x_axis": [0.0, 1.0, 0.0],
|
||||||
|
"y_axis": [-1.0, 0.0, 0.0],
|
||||||
|
"current": True,
|
||||||
|
}
|
||||||
|
# ...and the same empty name with the WCS frame really is world.
|
||||||
|
await backend.ucs_restore("world")
|
||||||
|
rows = await backend.ucs_list()
|
||||||
|
assert [r["name"] for r in rows] == ["world", "FRONT"]
|
||||||
|
assert rows[0]["current"] is True and rows[1]["current"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ucs_list_a_stale_name_falls_back_to_the_frame(backend):
|
||||||
|
"""``$UCSNAME`` naming an entry the table no longer holds is not a
|
||||||
|
current row; the frame decides between world and unnamed."""
|
||||||
|
header = backend._doc.header
|
||||||
|
header["$UCSNAME"] = "GONE"
|
||||||
|
rows = await backend.ucs_list()
|
||||||
|
assert [(r["name"], r["current"]) for r in rows] == [("world", True)]
|
||||||
|
header["$UCSORG"] = (0.0, 20.0, 0.0)
|
||||||
|
rows = await backend.ucs_list()
|
||||||
|
assert [(r["name"], r["current"]) for r in rows] == [("world", False), (None, True)]
|
||||||
|
assert rows[1]["origin"] == [0.0, 20.0, 0.0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ucs_list_tool_reports_an_unnamed_ucs_over_the_wire(monkeypatch, tmp_path):
|
||||||
|
"""The exact file AutoCAD writes after ``_.UCS _O 0,20,0`` without saving:
|
||||||
|
``$UCSNAME=''`` and ``$UCSORG=(0,20,0)``. The tool used to answer
|
||||||
|
``current: "world"``."""
|
||||||
|
import ezdxf
|
||||||
|
|
||||||
|
doc = ezdxf.new("R2018")
|
||||||
|
doc.ucs.add(
|
||||||
|
"FRONT", dxfattribs={"origin": (10, 10, 0), "xaxis": (0, 1, 0), "yaxis": (-1, 0, 0)}
|
||||||
|
)
|
||||||
|
doc.header["$UCSNAME"] = ""
|
||||||
|
doc.header["$UCSORG"] = (0.0, 20.0, 0.0)
|
||||||
|
path = tmp_path / "unnamed_ucs.dxf"
|
||||||
|
doc.saveas(str(path))
|
||||||
|
|
||||||
|
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||||
|
async with Client(server.mcp) as client:
|
||||||
|
await client.call_tool("drawing_open", {"path": str(path)})
|
||||||
|
payload = (await client.call_tool("ucs_list", {})).structured_content
|
||||||
|
assert payload["current"] is None and payload["current_unnamed"] is True
|
||||||
|
assert payload["count"] == 3
|
||||||
|
assert [r["name"] for r in payload["ucs"]] == ["world", "FRONT", None]
|
||||||
|
assert payload["ucs"][0]["current"] is False
|
||||||
|
assert payload["ucs"][2]["current"] is True
|
||||||
|
assert payload["ucs"][2]["origin"] == [0.0, 20.0, 0.0]
|
||||||
|
|
||||||
|
|
||||||
# ── live engine, against a fake ActiveX surface ──────────────────────────────
|
# ── live engine, against a fake ActiveX surface ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -311,7 +389,14 @@ def com_backend(monkeypatch):
|
|||||||
from backends import com_backend as module
|
from backends import com_backend as module
|
||||||
|
|
||||||
document = _FakeDocument()
|
document = _FakeDocument()
|
||||||
variables = {"CMDACTIVE": 0, "UCSNAME": ""}
|
variables = {
|
||||||
|
"CMDACTIVE": 0,
|
||||||
|
"UCSNAME": "",
|
||||||
|
"WORLDUCS": 1,
|
||||||
|
"UCSORG": (0.0, 0.0, 0.0),
|
||||||
|
"UCSXDIR": (1.0, 0.0, 0.0),
|
||||||
|
"UCSYDIR": (0.0, 1.0, 0.0),
|
||||||
|
}
|
||||||
app = types.SimpleNamespace(GetVariable=lambda name: variables[name])
|
app = types.SimpleNamespace(GetVariable=lambda name: variables[name])
|
||||||
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
||||||
monkeypatch.setattr(module, "_acad_app", lambda: app)
|
monkeypatch.setattr(module, "_acad_app", lambda: app)
|
||||||
@@ -372,6 +457,33 @@ async def test_com_ucs_add_uses_points_on_the_axes_not_vectors(com_backend):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_com_ucs_list_reads_worlducs_not_the_empty_name(com_backend):
|
||||||
|
"""Verified live on AutoCAD 2026: after ``_.UCS _O 10,10,0`` (unnamed),
|
||||||
|
``UCSNAME=""`` and ``WORLDUCS=0`` — the same empty name WCS has."""
|
||||||
|
backend, document, variables = com_backend
|
||||||
|
await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
|
||||||
|
variables.update(
|
||||||
|
{
|
||||||
|
"UCSNAME": "",
|
||||||
|
"WORLDUCS": 0,
|
||||||
|
"UCSORG": (0.0, 20.0, 0.0),
|
||||||
|
"UCSXDIR": (1.0, 0.0, 0.0),
|
||||||
|
"UCSYDIR": (0.0, 1.0, 0.0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows = await backend.ucs_list()
|
||||||
|
assert [(r["name"], r["current"]) for r in rows] == [
|
||||||
|
("world", False),
|
||||||
|
("FRONT", False),
|
||||||
|
(None, True),
|
||||||
|
]
|
||||||
|
assert rows[2]["origin"] == [0.0, 20.0, 0.0] and rows[2]["x_axis"] == [1.0, 0.0, 0.0]
|
||||||
|
# WORLDUCS=1 with the same empty name is world, and no unnamed row appears.
|
||||||
|
variables.update({"WORLDUCS": 1, "UCSORG": (0.0, 0.0, 0.0)})
|
||||||
|
rows = await backend.ucs_list()
|
||||||
|
assert [(r["name"], r["current"]) for r in rows] == [("world", True), ("FRONT", False)]
|
||||||
|
|
||||||
|
|
||||||
async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend):
|
async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend):
|
||||||
backend, document, variables = com_backend
|
backend, document, variables = com_backend
|
||||||
result = await backend.ucs_restore("world")
|
result = await backend.ucs_restore("world")
|
||||||
|
|||||||
Reference in New Issue
Block a user