engineering/understand/topology.py reads a line network (LINE, polylines with true bulge arcs, ARC) on the chosen layers and reports dangling ends, near misses (end-end and end-segment within gap, one row per pair) and interior crossings of two straight segments of one layer. An end on another network line is joined; an end on any curve of another layer or inside a block reference's box is attached; a line drawn twice is not a join, so a doubled pipe still dangles. gap defaults to 0.2 % of the network diagonal, never under ten tol. drawing_topology_check joins drawing_diff in SECTION 26: default layers are those classify_layer calls piping or electrical at >= 0.9, reported with their classification; no such layer is a refusal, not a pass. topo_dangling_endpoint (warning), topo_near_miss (error) and topo_interior_crossing (info) join the closed enum and run under focus=None, excluding the P&ID and architectural layer sets, so a pid_from_spec sheet and an arch_plan_from_spec plan get none of them; a drawing with no candidate layer is never snapshotted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
102 lines
3.9 KiB
Python
102 lines
3.9 KiB
Python
"""drawing_topology_check over the MCP wire, on the headless engine."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ezdxf
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastmcp import Client
|
|
|
|
import server
|
|
from tests.fixtures.plant_pair import build_plant_pair
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def pipe_layer(tmp_path_factory) -> str:
|
|
truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
|
|
return next(
|
|
name for name, service in sorted(truth["layer_services"].items()) if service == "product"
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(monkeypatch):
|
|
"""In-memory client on a fresh headless document - never the operator's AutoCAD."""
|
|
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
|
async with Client(server.mcp) as connected:
|
|
status = (await connected.call_tool("system_status", {})).structured_content or {}
|
|
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
|
|
await connected.call_tool("drawing_new", {})
|
|
yield connected
|
|
|
|
|
|
async def _line(client, layer, x1, y1, x2, y2):
|
|
await client.call_tool(
|
|
"entity_create_line", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "layer": layer}
|
|
)
|
|
|
|
|
|
async def _total(client) -> int:
|
|
stats = (await client.call_tool("analysis_entity_stats", {})).structured_content
|
|
return stats["total_entities"]
|
|
|
|
|
|
async def test_the_default_layers_are_the_classified_network_layers(client, pipe_layer):
|
|
await _line(client, pipe_layer, 0, 0, 1000, 0)
|
|
await _line(client, pipe_layer, 1003, 0, 2003, 0)
|
|
before = await _total(client)
|
|
result = (await client.call_tool("drawing_topology_check", {})).structured_content
|
|
assert result["layers"] == [pipe_layer]
|
|
assert [row["layer"] for row in result["classification"]] == [pipe_layer]
|
|
assert result["counts"] == {"dangling": 2, "near_miss": 1, "crossing": 0}
|
|
assert result["near_miss"][0]["gap"] == pytest.approx(3.0)
|
|
assert await _total(client) == before, "the check never writes"
|
|
|
|
|
|
async def test_a_dxf_path_is_read_without_opening_it(client, pipe_layer, tmp_path):
|
|
doc = ezdxf.new()
|
|
doc.layers.add(pipe_layer)
|
|
msp = doc.modelspace()
|
|
msp.add_line((0, 0), (1000, 0), dxfattribs={"layer": pipe_layer})
|
|
msp.add_line((500, -500), (500, 500), dxfattribs={"layer": pipe_layer})
|
|
path = tmp_path / "crossing.dxf"
|
|
doc.saveas(path)
|
|
result = (
|
|
await client.call_tool("drawing_topology_check", {"path": str(path), "gap": 5.0})
|
|
).structured_content
|
|
assert result["counts"]["crossing"] == 1
|
|
assert result["crossing"][0]["at"] == [500.0, 0.0]
|
|
assert await _total(client) == 0, "the current document is untouched"
|
|
|
|
|
|
async def test_an_unknown_layer_is_refused_with_the_drawings_layers(client, pipe_layer):
|
|
await _line(client, pipe_layer, 0, 0, 10, 0)
|
|
result = await client.call_tool(
|
|
"drawing_topology_check", {"layers": ["NOPE"]}, raise_on_error=False
|
|
)
|
|
assert result.is_error is True
|
|
assert "layers: 'NOPE' is not a layer of this drawing" in result.content[0].text
|
|
|
|
|
|
async def test_a_drawing_with_no_network_layer_is_refused_not_passed(client):
|
|
await _line(client, "GEOMETRY", 0, 0, 10, 0)
|
|
result = await client.call_tool("drawing_topology_check", {}, raise_on_error=False)
|
|
assert result.is_error is True
|
|
assert "no layer of this drawing is classified piping or electrical" in result.content[0].text
|
|
|
|
|
|
async def test_gap_must_exceed_tol(client, pipe_layer):
|
|
await _line(client, pipe_layer, 0, 0, 10, 0)
|
|
result = await client.call_tool(
|
|
"drawing_topology_check", {"gap": 0.005, "tol": 0.01}, raise_on_error=False
|
|
)
|
|
assert result.is_error is True
|
|
assert "gap (0.005) must exceed tol (0.01)" in result.content[0].text
|
|
|
|
|
|
async def test_the_check_is_advertised_read_only(client):
|
|
tools = {tool.name: tool for tool in await client.list_tools()}
|
|
assert tools["drawing_topology_check"].annotations.readOnlyHint is True
|