feat(understand): drawing_topology_check and the three topo_* critique focuses

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
This commit is contained in:
Umut CAN
2026-09-24 12:20:24 +03:00
co-authored by Claude Opus 5.5
parent 30f886c240
commit 2b74bf20a6
12 changed files with 1585 additions and 15 deletions
+3 -3
View File
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 242 tools · 8 resources · 5 prompt templates · 4516 collected tests.
> 242 is the **registered** count; a default install advertises 237 over `tools/list`,
> **v1.5 release snapshot:** 243 tools · 8 resources · 5 prompt templates · 4516 collected tests.
> 243 is the **registered** count; a default install advertises 238 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -122,7 +122,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
<sub>242 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (<code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>243 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (<code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
+12
View File
@@ -2478,6 +2478,18 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"revizyonlar arasi degisiklik",
),
),
"drawing_topology_check": ToolAliases(
synonyms=(
"dangling pipe ends",
"pipes that do not connect",
"gaps in the pipe network",
"unconnected cable ends",
"network connectivity check",
"açık uçlar",
"acik uclar",
"kopuk hatlar",
),
),
}
+16 -2
View File
@@ -3,13 +3,13 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 242,
"tools": 243,
"resources": 8,
"prompts": 5,
"groups": 26
},
"groups": {
"analysis": 13,
"analysis": 14,
"architecture": 12,
"batch": 3,
"blocks": 9,
@@ -1175,6 +1175,20 @@
"destructiveHint": false
}
},
{
"name": "drawing_topology_check",
"group": "analysis",
"cost": "read",
"summary": "Find dangling ends, near misses and unjoined crossings in a pipe or cable network.",
"tags": [
"analysis",
"query"
],
"annotations": {
"title": "Drawing: Topology Check",
"readOnlyHint": true
}
},
{
"name": "drawing_undo",
"group": "drawing",
+4
View File
@@ -420,6 +420,10 @@ from .arch.critique import ARCH_DISPATCH # noqa: E402 (same reason: after the
_FOCUS_DISPATCH.update(ARCH_DISPATCH)
from .understand.critique import TOPO_DISPATCH # noqa: E402 (same reason: after the base focuses)
_FOCUS_DISPATCH.update(TOPO_DISPATCH)
async def run_critique(
backend: AutoCADBackend,
+6
View File
@@ -36,6 +36,9 @@ CritiqueFocus = Literal[
"arch_room_unlabelled",
"arch_wall_gap",
"arch_opening_clash",
"topo_dangling_endpoint",
"topo_near_miss",
"topo_interior_crossing",
]
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
@@ -62,6 +65,9 @@ ALL_CRITIQUE_FOCUSES: tuple[CritiqueFocus, ...] = (
"arch_room_unlabelled",
"arch_wall_gap",
"arch_opening_clash",
"topo_dangling_endpoint",
"topo_near_miss",
"topo_interior_crossing",
)
+205
View File
@@ -0,0 +1,205 @@
"""The three topology critique focuses (spec §11).
``topo_dangling_endpoint`` (warning), ``topo_near_miss`` (error) and
``topo_interior_crossing`` (info) run ``topology.topology_findings`` - the
engine ``drawing_topology_check`` runs - once per critique run, shared through
``run_critique``'s context the way the P&ID, mechanical and architectural
focuses share theirs.
Which layers: the network layers ``vocab.classify_layer`` calls piping or
electrical with confidence >= 0.9, **minus every layer a semantic focus
already reads**: the P&ID layer set and line layers (``pid_*``) and the
architectural layer set (``arch_*``). One defect is never scored twice, and a
drawing made entirely by this server - a ``pid_from_spec`` sheet, an
``arch_plan_from_spec`` plan, a mechanical part - gets none of these issues.
Cost: the layer table is read first; a drawing with no candidate layer (every
drawing this server makes) is never snapshotted, so ``focus=None`` stays cheap.
A snapshot that cannot be taken is reported as one ``info`` issue per focus,
never as a clean result.
Severities are this module's decision: a near miss is an ``error`` (a network
that looks connected and is not - a takeoff counts two runs), a dangling end a
``warning`` (it may be a real open end), a crossing ``info`` (often drawn on
purpose).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from backends.quarantine import DocumentQuarantineError
from engineering.arch.layers import ARCH_LAYER_ROLES, ARCH_LAYERS
from engineering.layers import PID_LAYERS
from engineering.pid.lines import PID_LINE_LAYERS
from engineering.plan_spec import Issue
from engineering.understand.topology import (
NETWORK_CONFIDENCE,
NETWORK_DISCIPLINES,
network_layers,
topology_findings,
)
from engineering.understand.vocab import classify_layer
if TYPE_CHECKING:
from backends.base import AutoCADBackend
__all__ = [
"SEMANTIC_LAYERS",
"TOPO_DISPATCH",
"TOPO_FOCUSES",
"build_index",
"focus_layers",
"issues_for",
]
TOPO_FOCUSES = ("topo_dangling_endpoint", "topo_near_miss", "topo_interior_crossing")
_KEY = "topo_index"
#: Layers the pid_* and arch_* focuses already read. Layer "0" is nobody's.
SEMANTIC_LAYERS: frozenset[str] = frozenset(
{name for name, *_rest in PID_LAYERS}
| set(PID_LINE_LAYERS)
| {name for name, *_rest in ARCH_LAYERS}
| set(ARCH_LAYER_ROLES.values())
) - {"0"}
def _is_candidate(name: str) -> bool:
if name in SEMANTIC_LAYERS:
return False
meaning = classify_layer(name)
return (
meaning.get("discipline") in NETWORK_DISCIPLINES
and float(meaning.get("confidence") or 0.0) >= NETWORK_CONFIDENCE
)
def focus_layers(snap) -> list[str]:
"""The network layers of ``snap`` no semantic focus covers."""
return [row["layer"] for row in network_layers(snap) if row["layer"] not in SEMANTIC_LAYERS]
async def build_index(backend: AutoCADBackend) -> dict:
"""One read of the drawing for the three focuses:
``{"layers": [...], "findings": dict | None, "error": str | None}``."""
try:
names = [layer.name for layer in await backend.layer_list()]
except DocumentQuarantineError:
raise
except Exception:
names = []
if not any(_is_candidate(name) for name in names):
return {"layers": [], "findings": None, "error": None}
from engineering.understand.snapshot import take_snapshot
try:
snap = await take_snapshot(backend, None)
except DocumentQuarantineError:
raise
except Exception as exc:
return {
"layers": sorted(n for n in names if _is_candidate(n)),
"findings": None,
"error": str(exc) or type(exc).__name__,
}
layers = focus_layers(snap)
if not layers:
return {"layers": [], "findings": None, "error": None}
return {"layers": layers, "findings": topology_findings(snap, layers=layers), "error": None}
def _dangling(findings: dict) -> list[Issue]:
return [
Issue(
"warning",
"topo_dangling_endpoint",
f"{row['handle']} on layer {row['layer']} ends at ({row['at'][0]:.3f}, "
f"{row['at'][1]:.3f}) touching nothing - no line, fitting or equipment is at that "
"end. Connect it, or confirm it is a real open end.",
[row["handle"]],
{"layer": row["layer"], "end": row["end"], "at": row["at"], "space": row["space"]},
)
for row in findings["dangling"]
]
def _near_miss(findings: dict) -> list[Issue]:
out = []
for row in findings["near_miss"]:
first, second = row["handles"]
what = "the end of" if row["kind"] == "end_end" else "the body of"
out.append(
Issue(
"error",
"topo_near_miss",
f"{first} on layer {row['layers'][0]} stops {row['gap']:.4g} short of {what} "
f"{second} without joining it: the network reads as two pieces there. Extend or "
"trim it onto the other line.",
sorted({first, second}),
{
"kind": row["kind"],
"gap": row["gap"],
"at": row["at"],
"to": row["to"],
"layers": row["layers"],
"space": row["space"],
},
)
)
return out
def _crossing(findings: dict) -> list[Issue]:
return [
Issue(
"info",
"topo_interior_crossing",
f"{row['handles'][0]} and {row['handles'][1]} on layer {row['layer']} cross at "
f"({row['at'][0]:.3f}, {row['at'][1]:.3f}) away from their ends with no junction. "
"If they connect, split both at the crossing; if not, draw the crossing with a break.",
sorted(set(row["handles"])),
{"layer": row["layer"], "at": row["at"], "space": row["space"]},
)
for row in findings["crossing"]
]
_CHECKS = {
"topo_dangling_endpoint": _dangling,
"topo_near_miss": _near_miss,
"topo_interior_crossing": _crossing,
}
def issues_for(focus: str, index: dict) -> list[Issue]:
if index.get("error"):
return [
Issue(
"info",
focus,
f"Topology not checked on {', '.join(index['layers'])}: the drawing could not be "
f"read ({index['error']}).",
[],
{"layers": list(index["layers"])},
)
]
findings = index.get("findings")
if not findings:
return []
return _CHECKS[focus](findings)
def _make(focus: str):
async def check(backend: AutoCADBackend, shared: dict) -> list[Issue]:
index = shared.get(_KEY)
if index is None:
index = shared[_KEY] = await build_index(backend)
return issues_for(focus, index)
check.needs_shared = True # type: ignore[attr-defined]
check.__name__ = f"check_{focus}"
return check
TOPO_DISPATCH = {focus: _make(focus) for focus in TOPO_FOCUSES}
+603
View File
@@ -0,0 +1,603 @@
"""The topology of a line network: dangling ends, near misses, interior crossings.
Spec §11. Pure: a ``Snapshot`` in, three lists out; nothing touches a drawing.
What is analysed: the entities on ``layers`` that draw a network - LINE,
LWPOLYLINE, POLYLINE (a bulged segment is a true arc) and ARC. Their *ends*
are the ends of the entity: a polyline's first and last vertex, never an
interior vertex, and a closed polyline has none. Everything else in the same
space is context: every LINE / LWPOLYLINE / POLYLINE / ARC / CIRCLE on another
layer (a tank outline, a wall), every CIRCLE on the analysed layers, and the
box of every INSERT on any layer (a valve, a pump, a reducer).
Each end is exactly one of, tested in this order:
* **joined** (no finding) - within ``tol`` of another network end, or of a
network segment other than its own segment and that segment's neighbours.
A polyline of three or more segments may join its own other end (a ring
drawn as an open polyline). A segment lying along the end's own segment and
running into it - a line or an arc drawn twice, a line over a polyline -
is not a join, so a pipe drawn twice dangles exactly like a pipe drawn once;
* **attached** (no finding) - within ``tol`` of a context curve, or inside or
within ``tol`` of an INSERT box: a line ending on equipment or on a fitting
is connected, whatever layer the equipment is on;
* **near miss** - the nearest network end or segment (the candidates of
*joined*) is within ``gap``: ``end_end`` when an end is nearest (an end
wins a tie), else ``end_segment``. One row per pair;
* **dangling** - none of the above.
An **interior crossing** is two straight network segments on the same layer
that cross at a point more than ``tol`` from every end of both segments: a
junction nobody drew, or a crossing drawn without a break. A crossing through
a polyline vertex reads as a touch and is not reported, arcs are not tested
for crossings, and two different layers crossing is how every plan is drawn (a
cable over a pipe), so it is not a finding. Spaces are analysed apart: model
space and each paper layout never join each other.
``gap`` defaults to 0.2 % of the diagonal of the analysed network's box, never
less than ten ``tol``. The 0.2 %, the ten-``tol`` floor and the same-layer
rule for crossings are this module's declared defaults, not standards values.
``EntityRecord.points[0]`` is the centre of an ARC or a CIRCLE (the reader in
``engineering.understand.snapshot``); an ARC or CIRCLE without one is skipped
and counted in ``stats["skipped"]``. Spatial lookups go through a uniform
grid, so a large drawing is read in one pass rather than in pairs of
entities.
"""
from __future__ import annotations
import math
from collections import Counter, defaultdict
from collections.abc import Iterable
from dataclasses import dataclass
from engineering.understand.snapshot import EntityRecord, Pt, Snapshot
from engineering.understand.vocab import classify_layer
__all__ = [
"DEFAULT_TOL",
"GAP_FLOOR_TOLS",
"GAP_FRACTION",
"NETWORK_CONFIDENCE",
"NETWORK_DISCIPLINES",
"Piece",
"cap_findings",
"default_gap",
"entity_pieces",
"network_layers",
"topology_findings",
]
DEFAULT_TOL = 0.01
#: The default gap, as a share of the analysed network's diagonal.
GAP_FRACTION = 0.002
#: ... and never less than this many ``tol``.
GAP_FLOOR_TOLS = 10.0
#: What makes a layer a network layer by default (spec §11).
NETWORK_DISCIPLINES = ("piping", "electrical")
NETWORK_CONFIDENCE = 0.9
FINDING_KINDS = ("dangling", "near_miss", "crossing")
_NETWORK_TYPES = frozenset({"LINE", "LWPOLYLINE", "POLYLINE", "ARC"})
_CONTEXT_TYPES = _NETWORK_TYPES | {"CIRCLE"}
_TWO_PI = 2.0 * math.pi
#: A piece whose box covers more grid cells than this is kept on a side list.
_MAX_CELLS = 4096
@dataclass(frozen=True)
class Piece:
"""One segment of an entity: straight, or an arc when ``centre`` is set.
``a`` and ``b`` are its ends as drawn. An arc runs counter-clockwise from
the angle ``start`` (radians) through ``sweep`` (radians, > 0).
"""
owner: str
layer: str
index: int
a: Pt
b: Pt
centre: Pt | None = None
radius: float = 0.0
start: float = 0.0
sweep: float = 0.0
def box(self) -> tuple[float, float, float, float]:
if self.centre is not None:
cx, cy = self.centre
r = self.radius
return (cx - r, cy - r, cx + r, cy + r)
return (
min(self.a[0], self.b[0]),
min(self.a[1], self.b[1]),
max(self.a[0], self.b[0]),
max(self.a[1], self.b[1]),
)
def nearest(self, p: Pt) -> Pt:
"""The point of this piece nearest to ``p``."""
if self.centre is None:
ax, ay = self.a
dx, dy = self.b[0] - ax, self.b[1] - ay
length2 = dx * dx + dy * dy
if length2 == 0.0:
return self.a
t = ((p[0] - ax) * dx + (p[1] - ay) * dy) / length2
t = min(1.0, max(0.0, t))
return (ax + t * dx, ay + t * dy)
cx, cy = self.centre
rel = (math.atan2(p[1] - cy, p[0] - cx) - self.start) % _TWO_PI
distance = math.hypot(p[0] - cx, p[1] - cy)
if rel <= self.sweep + 1e-12 and distance > 0.0:
return (
cx + self.radius * (p[0] - cx) / distance,
cy + self.radius * (p[1] - cy) / distance,
)
return self.a if math.dist(p, self.a) <= math.dist(p, self.b) else self.b
def _arc_from_bulge(owner: str, layer: str, index: int, a: Pt, b: Pt, bulge: float) -> Piece:
"""A polyline segment with a bulge: the arc it stands for.
The bulge is tan(sweep / 4), positive counter-clockwise (the DXF rule).
"""
dx, dy = b[0] - a[0], b[1] - a[1]
chord = math.hypot(dx, dy)
theta = 4.0 * math.atan(bulge)
if chord == 0.0 or theta == 0.0:
return Piece(owner, layer, index, a, b)
offset = chord / (2.0 * math.tan(theta / 2.0))
nx, ny = -dy / chord, dx / chord
centre = ((a[0] + b[0]) / 2.0 + nx * offset, (a[1] + b[1]) / 2.0 + ny * offset)
radius = abs(chord / (2.0 * math.sin(theta / 2.0)))
first = a if theta > 0 else b
start = math.atan2(first[1] - centre[1], first[0] - centre[0])
return Piece(owner, layer, index, a, b, centre, radius, start, abs(theta))
def _arc_piece(rec: EntityRecord, *, full: bool) -> Piece | None:
if not rec.points or rec.radius is None:
return None
centre = (float(rec.points[0][0]), float(rec.points[0][1]))
r = float(rec.radius)
if full or rec.angles is None:
a = b = (centre[0] + r, centre[1])
return Piece(rec.handle, rec.layer, 0, a, b, centre, r, 0.0, _TWO_PI)
s = math.radians(float(rec.angles[0]))
e = math.radians(float(rec.angles[1]))
sweep = (e - s) % _TWO_PI or _TWO_PI
a = (centre[0] + r * math.cos(s), centre[1] + r * math.sin(s))
b = (centre[0] + r * math.cos(e), centre[1] + r * math.sin(e))
return Piece(rec.handle, rec.layer, 0, a, b, centre, r, s, sweep)
def entity_pieces(rec: EntityRecord) -> tuple[list[Piece], tuple[Pt, Pt] | None] | None:
"""The pieces of one record and its two ends (None when it has none).
None for a record that draws no curve or lacks what it needs.
"""
kind = rec.type
if kind == "LINE":
if len(rec.points) < 2:
return None
a, b = tuple(rec.points[0]), tuple(rec.points[1])
return [Piece(rec.handle, rec.layer, 0, a, b)], (a, b)
if kind in ("LWPOLYLINE", "POLYLINE"):
points = [(float(p[0]), float(p[1])) for p in rec.points]
if len(points) < 2:
return None
bulges = list(rec.bulges) + [0.0] * (len(points) - len(rec.bulges))
count = len(points) if rec.closed else len(points) - 1
pieces = [
_arc_from_bulge(
rec.handle,
rec.layer,
i,
points[i],
points[(i + 1) % len(points)],
float(bulges[i]),
)
for i in range(count)
]
return pieces, None if rec.closed else (points[0], points[-1])
if kind == "ARC":
piece = _arc_piece(rec, full=False)
return None if piece is None else ([piece], (piece.a, piece.b))
if kind == "CIRCLE":
piece = _arc_piece(rec, full=True)
return None if piece is None else ([piece], None)
return None
def network_layers(snap: Snapshot) -> list[dict]:
"""The layers ``vocab.classify_layer`` calls piping or electrical with
confidence >= 0.9, with the classification as evidence."""
names = set(snap.layers) | {rec.layer for rec in snap.records}
rows = []
for name in sorted(names):
meaning = classify_layer(name)
confidence = float(meaning.get("confidence") or 0.0)
if meaning.get("discipline") in NETWORK_DISCIPLINES and confidence >= NETWORK_CONFIDENCE:
rows.append(
{
"layer": name,
"discipline": meaning.get("discipline"),
"service": meaning.get("service"),
"confidence": confidence,
"keyword": meaning.get("keyword"),
}
)
return rows
def default_gap(points: Iterable[Pt], tol: float) -> float:
"""0.2 % of the diagonal of the points' box, never less than ten ``tol``."""
xs: list[float] = []
ys: list[float] = []
for x, y in points:
xs.append(x)
ys.append(y)
diagonal = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) if xs else 0.0
return max(GAP_FRACTION * diagonal, GAP_FLOOR_TOLS * tol)
def _positive(name: str, value) -> float:
try:
number = float(value)
except (TypeError, ValueError):
raise ValueError(f"{name} must be a positive number; got {value!r}") from None
if not math.isfinite(number) or number <= 0:
raise ValueError(f"{name} must be a positive number; got {value!r}")
return number
def _layer_list(layers) -> set[str]:
if isinstance(layers, str):
layers = [layers]
names = {str(name) for name in (layers or []) if str(name)}
if not names:
raise ValueError("layers: name at least one layer to check")
return names
class _Grid:
"""A uniform grid of item indices by box; oversized boxes on a side list."""
def __init__(self, cell: float) -> None:
self.cell = cell
self.cells: dict[tuple[int, int], list[int]] = defaultdict(list)
self.oversize: list[int] = []
def _span(self, box) -> tuple[int, int, int, int]:
c = self.cell
return (
math.floor(box[0] / c),
math.floor(box[1] / c),
math.floor(box[2] / c),
math.floor(box[3] / c),
)
def add(self, item: int, box) -> None:
x0, y0, x1, y1 = self._span(box)
if (x1 - x0 + 1) * (y1 - y0 + 1) > _MAX_CELLS:
self.oversize.append(item)
return
for ix in range(x0, x1 + 1):
for iy in range(y0, y1 + 1):
self.cells[(ix, iy)].append(item)
def near(self, box) -> list[int]:
x0, y0, x1, y1 = self._span(box)
found: set[int] = set(self.oversize)
if (x1 - x0 + 1) * (y1 - y0 + 1) > _MAX_CELLS:
for items in self.cells.values():
found.update(items)
return sorted(found)
for ix in range(x0, x1 + 1):
for iy in range(y0, y1 + 1):
found.update(self.cells.get((ix, iy), ()))
return sorted(found)
def _around(p: Pt, r: float) -> tuple[float, float, float, float]:
return (p[0] - r, p[1] - r, p[0] + r, p[1] + r)
def _box_distance(p: Pt, box) -> float:
dx = max(box[0] - p[0], 0.0, p[0] - box[2])
dy = max(box[1] - p[1], 0.0, p[1] - box[3])
return math.hypot(dx, dy)
def _cross(ax: float, ay: float, bx: float, by: float) -> float:
return ax * by - ay * bx
def _crossing(p: Piece, q: Piece, tol: float) -> Pt | None:
"""Where two straight pieces cross strictly inside both, away from their ends."""
rx, ry = p.b[0] - p.a[0], p.b[1] - p.a[1]
sx, sy = q.b[0] - q.a[0], q.b[1] - q.a[1]
denominator = _cross(rx, ry, sx, sy)
if denominator == 0.0:
return None
qx, qy = q.a[0] - p.a[0], q.a[1] - p.a[1]
t = _cross(qx, qy, sx, sy) / denominator
u = _cross(qx, qy, rx, ry) / denominator
if not (0.0 < t < 1.0 and 0.0 < u < 1.0):
return None
point = (p.a[0] + t * rx, p.a[1] + t * ry)
if min(math.dist(point, e) for e in (p.a, p.b, q.a, q.b)) <= tol:
return None
return point
def _overlaps(own: Piece, point: Pt, other: Piece, tol: float) -> bool:
"""True when ``other`` lies along ``own`` and runs from ``point`` into it:
a line drawn twice, or a line over a polyline. Such a piece is not a join -
otherwise a pipe drawn twice would join itself and never dangle. A
collinear piece that continues the other way (a butt joint) is a join.
Arcs follow the same rule: an arc on the same circle (centre and radius
within ``tol``) that covers ``own`` just inside ``point`` - an ARC drawn
twice, an ARC over a bulged polyline segment - is not a join; a straight
piece never lies along an arc."""
if (own.centre is None) != (other.centre is None):
return False
if own.centre is not None:
if math.dist(own.centre, other.centre) > tol or abs(own.radius - other.radius) > tol:
return False
if own.radius <= 0.0:
return False
cx, cy = own.centre
# ``own`` runs counter-clockwise from ``start`` through ``sweep``; probe
# 2 tol of arc (at most half the sweep) inside it from whichever end
# ``point`` is.
step = min(2.0 * tol / own.radius, own.sweep / 2.0)
first = (cx + own.radius * math.cos(own.start), cy + own.radius * math.sin(own.start))
last_angle = own.start + own.sweep
last = (cx + own.radius * math.cos(last_angle), cy + own.radius * math.sin(last_angle))
angle = (
own.start + step
if math.dist(point, first) <= math.dist(point, last)
else (last_angle - step)
)
probe = (cx + own.radius * math.cos(angle), cy + own.radius * math.sin(angle))
return math.dist(probe, other.nearest(probe)) <= tol
far = own.b if math.dist(point, own.a) <= math.dist(point, own.b) else own.a
length = math.dist(point, far)
if length == 0.0:
return False
ux, uy = (far[0] - point[0]) / length, (far[1] - point[1]) / length
along = []
for p in (other.a, other.b):
dx, dy = p[0] - point[0], p[1] - point[1]
if abs(dy * ux - dx * uy) > tol:
return False
along.append(dx * ux + dy * uy)
return max(along) > tol
def _analyse_space(
space: str,
records: list[EntityRecord],
wanted: set[str],
gap: float,
tol: float,
out: dict,
skipped: Counter,
) -> tuple[int, int]:
net: list[Piece] = []
counts: dict[str, int] = {}
closed: set[str] = set()
# handle, layer, "start" | "end", the point, the index in `net` of the piece it ends
ends: list[tuple[str, str, str, Pt, int]] = []
context: list[Piece] = []
boxes: list[tuple[float, float, float, float]] = []
for rec in records:
if rec.type == "INSERT":
if rec.bbox is not None:
boxes.append(tuple(float(v) for v in rec.bbox))
continue
if rec.type not in _CONTEXT_TYPES:
continue
built = entity_pieces(rec)
if built is None:
skipped[rec.type] += 1
continue
pieces, entity_ends = built
if rec.layer in wanted and rec.type in _NETWORK_TYPES:
first = len(net)
net.extend(pieces)
counts[rec.handle] = len(pieces)
if entity_ends is None:
closed.add(rec.handle)
else:
ends.append((rec.handle, rec.layer, "start", entity_ends[0], first))
ends.append((rec.handle, rec.layer, "end", entity_ends[1], len(net) - 1))
else:
context.extend(pieces)
if not net:
return 0, 0
cell = max(8.0 * gap, 100.0 * tol)
net_grid = _Grid(cell)
for k, piece in enumerate(net):
net_grid.add(k, piece.box())
end_grid = _Grid(cell)
for k, (_h, _l, _w, point, _piece) in enumerate(ends):
end_grid.add(k, _around(point, 0.0))
context_grid = _Grid(cell)
for k, piece in enumerate(context):
context_grid.add(k, piece.box())
box_grid = _Grid(cell)
for k, box in enumerate(boxes):
box_grid.add(k, box)
seen_pairs: set = set()
for handle, layer, which, point, own_k in sorted(ends, key=lambda e: (e[0], e[2])):
own = net[own_k]
pieces_of_owner = counts[handle]
own_index = 0 if which == "start" else pieces_of_owner - 1
reach = _around(point, gap)
best_end: tuple[float, int] | None = None
for k in end_grid.near(reach):
other_handle, _ol, other_which, other_point, other_k = ends[k]
if other_handle == handle and (other_which == which or pieces_of_owner < 3):
continue
if _overlaps(own, point, net[other_k], tol):
continue
distance = math.dist(point, other_point)
if best_end is None or distance < best_end[0]:
best_end = (distance, k)
best_piece: tuple[float, int, Pt] | None = None
for k in net_grid.near(reach):
piece = net[k]
if piece.owner == handle and abs(piece.index - own_index) <= 1:
continue
if _overlaps(own, point, piece, tol):
continue
foot = piece.nearest(point)
distance = math.dist(point, foot)
if best_piece is None or distance < best_piece[0]:
best_piece = (distance, k, foot)
nearest = min(
best_end[0] if best_end is not None else math.inf,
best_piece[0] if best_piece is not None else math.inf,
)
if nearest <= tol:
continue # joined
touch = _around(point, tol)
if any(
math.dist(point, context[k].nearest(point)) <= tol for k in context_grid.near(touch)
) or any(_box_distance(point, boxes[k]) <= tol for k in box_grid.near(touch)):
continue # attached
if nearest <= gap:
if best_end is not None and (best_piece is None or best_end[0] <= best_piece[0]):
other_handle, other_layer, other_which, other_point, _k = ends[best_end[1]]
key = frozenset({(handle, which), (other_handle, other_which)})
kind, distance, to = "end_end", best_end[0], other_point
else:
piece = net[best_piece[1]]
other_handle, other_layer = piece.owner, piece.layer
key = (handle, which, other_handle)
kind, distance, to = "end_segment", best_piece[0], best_piece[2]
if key in seen_pairs:
continue
seen_pairs.add(key)
out["near_miss"].append(
{
"handles": [handle, other_handle],
"layers": [layer, other_layer],
"kind": kind,
"gap": distance,
"at": [point[0], point[1]],
"to": [to[0], to[1]],
"space": space,
}
)
continue
out["dangling"].append(
{
"handle": handle,
"layer": layer,
"end": which,
"at": [point[0], point[1]],
"space": space,
}
)
for k, piece in enumerate(net):
if piece.centre is not None:
continue
for m in net_grid.near(piece.box()):
if m <= k:
continue
other = net[m]
if other.centre is not None or other.layer != piece.layer:
continue
if other.owner == piece.owner:
apart = abs(other.index - piece.index)
wraps = piece.owner in closed and apart == counts[piece.owner] - 1
if apart <= 1 or wraps:
continue
point = _crossing(piece, other, tol)
if point is None:
continue
out["crossing"].append(
{
"handles": [piece.owner, other.owner],
"layer": piece.layer,
"at": [point[0], point[1]],
"space": space,
}
)
return len(counts), len(ends)
def topology_findings(
snap: Snapshot, *, layers, gap: float | None = None, tol: float = DEFAULT_TOL
) -> dict:
"""Dangling ends, near misses and interior crossings on ``layers`` (spec §11).
Refused before any work: an empty ``layers``, a non-positive ``tol`` or
``gap``, and a ``gap`` that does not exceed ``tol``.
"""
tol = _positive("tol", tol)
wanted = _layer_list(layers)
if gap is not None:
gap = _positive("gap", gap)
if gap <= tol:
raise ValueError(
f"gap ({gap:g}) must exceed tol ({tol:g}): an end within tol is joined, and a "
"near miss is a distance between the two"
)
by_space: dict[str, list[EntityRecord]] = defaultdict(list)
for rec in snap.records:
by_space[rec.space].append(rec)
if gap is None:
points: list[Pt] = []
for rec in snap.records:
if rec.layer in wanted and rec.type in _NETWORK_TYPES:
built = entity_pieces(rec)
if built is not None:
for piece in built[0]:
points.extend((piece.a, piece.b))
gap = default_gap(points, tol)
out: dict = {kind: [] for kind in FINDING_KINDS}
skipped: Counter = Counter()
entities = end_count = 0
for space in sorted(by_space):
n_entities, n_ends = _analyse_space(space, by_space[space], wanted, gap, tol, out, skipped)
entities += n_entities
end_count += n_ends
out["dangling"].sort(key=lambda r: (r["space"], r["handle"], r["end"]))
out["near_miss"].sort(key=lambda r: (r["space"], r["handles"], r["at"]))
out["crossing"].sort(key=lambda r: (r["space"], r["handles"], r["at"]))
return {
"layers": sorted(wanted),
"gap": gap,
"tol": tol,
**out,
"stats": {
"entities": entities,
"ends": end_count,
"skipped": dict(sorted(skipped.items())),
},
}
def cap_findings(findings: dict, limit: int) -> dict:
"""``findings`` with each list cut to ``limit`` rows, plus ``counts`` and
``truncated`` per kind."""
if int(limit) < 1:
raise ValueError(f"limit must be at least 1; got {limit!r}")
capped = dict(findings)
capped["counts"] = {kind: len(findings.get(kind) or []) for kind in FINDING_KINDS}
capped["truncated"] = {
kind: max(0, capped["counts"][kind] - int(limit)) for kind in FINDING_KINDS
}
for kind in FINDING_KINDS:
capped[kind] = list(findings.get(kind) or [])[: int(limit)]
return capped
+109 -3
View File
@@ -5746,7 +5746,9 @@ async def drawing_finalize(
mech_view_misaligned, mech_duplicate_dimension, mech_thread_unrepresented,
mech_bom_balloon_mismatch — silent on a sheet with no mechanical part, plus the
architectural focuses arch_room_unlabelled, arch_wall_gap, arch_opening_clash —
silent on a sheet with no ACADMCP_ARCH record),
silent on a sheet with no ACADMCP_ARCH record, plus the topology focuses
topo_dangling_endpoint, topo_near_miss, topo_interior_crossing — on network
layers no P&ID or architectural focus reads, silent on every sheet this server draws),
then saves to disk, exports a screenshot, and returns the DWG path.
Raises ToolError if any validator 'error' finding is present, or if critique reports an
@@ -6009,7 +6011,9 @@ async def drawing_critique(
"mech_duplicate_dimension, mech_thread_unrepresented, "
"mech_bom_balloon_mismatch (silent on a drawing with no mechanical part), and "
"the architectural focuses arch_room_unlabelled, arch_wall_gap, "
"arch_opening_clash (silent on a drawing with no ACADMCP_ARCH record). "
"arch_opening_clash (silent on a drawing with no ACADMCP_ARCH record), and "
"the topology focuses topo_dangling_endpoint, topo_near_miss, "
"topo_interior_crossing (network layers no P&ID or architectural focus reads). "
"None = run all.",
),
] = None,
@@ -10555,7 +10559,7 @@ async def arch_plan_from_spec(
# ---------------------------------------------------------------------------
# ── SECTION 26: Understanding & QA (1 tool) ─────────────────────────────────
# ── SECTION 26: Understanding & QA (2 tools) ────────────────────────────────
# ---------------------------------------------------------------------------
@@ -10768,6 +10772,108 @@ async def drawing_diff(
return report
@cad_tool(
summary="Find dangling ends, near misses and unjoined crossings in a pipe or cable network.",
cost="read",
)
@mcp.tool(
annotations={"title": "Drawing: Topology Check", "readOnlyHint": True},
tags={"analysis", "query"},
)
async def drawing_topology_check(
layers: Annotated[
list[str] | None,
Field(
default=None,
description="Layers to check; default: every layer the vocabulary classifies as "
"piping or electrical with confidence >= 0.9.",
),
] = None,
path: Annotated[
str | None,
Field(
default=None,
description="A DXF to check instead of the current document; it is read, never "
"opened or modified.",
),
] = None,
gap: Annotated[
float | None,
Field(
default=None,
gt=0,
description="Two ends, or an end and a line, closer than this without touching are a "
"near miss; default 0.2 % of the network's diagonal (never under 10 x tol).",
),
] = None,
tol: Annotated[
float,
Field(default=0.01, gt=0, description="Within this distance two ends are joined."),
] = 0.01,
limit: Annotated[
int,
Field(default=200, ge=1, le=5000, description="Rows kept per list; counts are complete."),
] = 200,
ctx: Context = None,
) -> dict:
"""Topology of a line network (spec §11): dangling ends, near misses (end to end and
end to line within `gap`) and interior crossings (two lines of one layer crossing
away from their ends). Never modifies the drawing.
An end that touches another network line, any curve on another layer (a tank
outline) or the box of any block reference (a valve, a pump) is connected. Arcs
and bulged polyline segments are measured as true arcs; arcs are not tested for
crossings. The default layers come with their classification as evidence.
Refused before any work: a `path` that does not exist or cannot be read, a
`layers` entry that is not a layer of the drawing (the drawing's layers named),
no layer classified as a network layer when `layers` is omitted (name them),
a non-positive `tol` or `gap`, and a `gap` that does not exceed `tol`.
"""
from engineering.understand.snapshot import take_snapshot
from engineering.understand.topology import cap_findings, network_layers, topology_findings
backend = _backend(ctx)
source = None
if path:
validated = validate_path(path)
if not validated.is_file():
raise ToolError(f"path: {validated} does not exist or is not a file")
source = str(validated)
try:
snap = await take_snapshot(backend, source)
except (OSError, ValueError) as exc:
raise ToolError(f"path: {exc}") from exc
known = set(snap.layers) | {rec.layer for rec in snap.records}
classification = network_layers(snap)
if layers:
missing = [name for name in layers if name not in known]
if missing:
listed = ", ".join(sorted(known)[:20]) + (" ..." if len(known) > 20 else "")
raise ToolError(
f"layers: {', '.join(repr(m) for m in missing)} is not a layer of this drawing; "
f"its layers are {listed}"
)
chosen = list(layers)
else:
chosen = [row["layer"] for row in classification]
if not chosen:
raise ToolError(
"no layer of this drawing is classified piping or electrical with confidence "
">= 0.9 - name the layers to check with `layers` (drawing_understand shows what "
"each layer carries)"
)
await ctx.info(f"drawing_topology_check: {len(chosen)} layer(s) of {snap.source}")
try:
findings = topology_findings(snap, layers=chosen, gap=gap, tol=tol)
except ValueError as exc:
raise ToolError(str(exc)) from exc
report = cap_findings(findings, limit)
report["source"] = snap.source
report["classification"] = [row for row in classification if row["layer"] in set(chosen)]
return report
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+8 -7
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (242 tools, 26 groups).
"""Frozen snapshot of the surface (243 tools, 26 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -593,14 +593,15 @@ async def test_tool_group_sizes_are_unchanged():
pre-track-F value.
Track H group D opened its copy of SECTION 26 (Understanding & QA) with
`drawing_diff`, tagged `analysis` and `query`; `analysis` outranks `query`
in `_GROUP_TAG_PRIORITY`, so it files under `analysis` (12 -> 13). This
snapshot is branch-local: the track H merge recomputes it once the
understand, network and diff branches have landed.
`drawing_diff` and `drawing_topology_check`, both tagged `analysis` and
`query`; `analysis` outranks `query` in `_GROUP_TAG_PRIORITY`, so both file
under `analysis` (12 -> 14). This snapshot is branch-local: the track H
merge recomputes it once the understand, network and diff branches have
landed.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 13,
"analysis": 14,
"architecture": 12,
"batch": 3,
"blocks": 9,
@@ -627,4 +628,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 242
assert sum(sizes.values()) == 243
+201
View File
@@ -0,0 +1,201 @@
"""The three topo_* focuses: a positive and a negative fixture each, and silence
on every drawing this server makes.
The network layer is the synthetic plant's product-piping layer, read from the
fixture's truth dict (``tests/fixtures/plant_pair.py``), so the test draws on a
layer the vocabulary classifies the way a foreign drawing's layer would be
classified.
"""
from __future__ import annotations
import copy
import pytest
from engineering.arch.layers import ARCH_ROLE_LAYER
from engineering.critique import run_critique
from engineering.pid.lines import PID_LINE_LAYERS
from engineering.plan_spec import ALL_CRITIQUE_FOCUSES
from engineering.understand.critique import (
SEMANTIC_LAYERS,
TOPO_DISPATCH,
TOPO_FOCUSES,
build_index,
focus_layers,
issues_for,
)
from engineering.understand.snapshot import EntityRecord, Snapshot
from engineering.understand.vocab import classify_layer
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"))
layer = next(
name for name, service in sorted(truth["layer_services"].items()) if service == "product"
)
meaning = classify_layer(layer)
assert meaning["discipline"] == "piping" and meaning["confidence"] >= 0.9, meaning
return layer
async def _lines(backend, layer, *segments):
handles = []
for x1, y1, x2, y2 in segments:
entity = await backend.entity_create_line(x1, y1, x2, y2, layer=layer)
handles.append(entity.handle)
return handles
async def _issues(backend, focus):
return issues_for(focus, await build_index(backend))
# -- the closed enum and the exclusions ------------------------------------------------
async def test_the_three_focuses_are_the_spec_ones_and_are_in_the_closed_enum():
assert TOPO_FOCUSES == ("topo_dangling_endpoint", "topo_near_miss", "topo_interior_crossing")
for focus in TOPO_FOCUSES:
assert focus in ALL_CRITIQUE_FOCUSES
assert TOPO_DISPATCH[focus].needs_shared is True
async def test_pid_line_layers_and_arch_layers_are_excluded():
assert set(PID_LINE_LAYERS) <= SEMANTIC_LAYERS
assert ARCH_ROLE_LAYER["wall"] in SEMANTIC_LAYERS
assert "0" not in SEMANTIC_LAYERS
snap = Snapshot(
source="test",
insunits=4,
extmin=None,
extmax=None,
layers={"PROCESS-PIPING-MAIN": {}, "A-WALL-E-N": {}},
layouts=(),
records=(
EntityRecord("1", "LINE", "PROCESS-PIPING-MAIN", "Model", ((0, 0), (10, 0))),
EntityRecord("2", "LINE", "A-WALL-E-N", "Model", ((0, 5), (10, 5))),
),
)
assert focus_layers(snap) == []
# -- topo_dangling_endpoint ------------------------------------------------------------
async def test_dangling_fires_on_a_lone_pipe(backend, pipe_layer):
(handle,) = await _lines(backend, pipe_layer, (0, 0, 1000, 0))
issues = await _issues(backend, "topo_dangling_endpoint")
assert [(i.severity, i.handles, i.detail["end"]) for i in issues] == [
("warning", [handle], "end"),
("warning", [handle], "start"),
]
async def test_dangling_is_quiet_on_a_closed_loop(backend, pipe_layer):
await _lines(
backend,
pipe_layer,
(0, 0, 1000, 0),
(1000, 0, 1000, 1000),
(1000, 1000, 0, 1000),
(0, 1000, 0, 0),
)
assert await _issues(backend, "topo_dangling_endpoint") == []
# -- topo_near_miss --------------------------------------------------------------------
async def test_near_miss_fires_on_a_3_unit_gap(backend, pipe_layer):
# box 0..2003 wide: default gap 0.002 x 2003 = 4.006 > 3
first, second = await _lines(backend, pipe_layer, (0, 0, 1000, 0), (1003, 0, 2003, 0))
(issue,) = await _issues(backend, "topo_near_miss")
assert issue.severity == "error"
assert issue.handles == sorted([first, second])
assert issue.detail["kind"] == "end_end"
assert issue.detail["gap"] == pytest.approx(3.0)
assert "stops 3 short of the end of" in issue.message
async def test_near_miss_is_quiet_when_the_lines_join(backend, pipe_layer):
await _lines(backend, pipe_layer, (0, 0, 1000, 0), (1000, 0, 2000, 0))
assert await _issues(backend, "topo_near_miss") == []
# -- topo_interior_crossing ------------------------------------------------------------
async def test_crossing_fires_on_two_pipes_crossing_mid_span(backend, pipe_layer):
await _lines(backend, pipe_layer, (0, 0, 1000, 0), (500, -500, 500, 500))
(issue,) = await _issues(backend, "topo_interior_crossing")
assert issue.severity == "info"
assert issue.detail["at"] == [500.0, 0.0]
async def test_crossing_is_quiet_on_a_t_junction(backend, pipe_layer):
await _lines(backend, pipe_layer, (0, 0, 1000, 0), (500, 0, 500, 500))
assert await _issues(backend, "topo_interior_crossing") == []
# -- silence on this server's own drawings ---------------------------------------------
async def test_a_pid_from_spec_sheet_gets_no_topo_issue(backend):
from engineering.pid.spec import EXAMPLE_SPEC, run_spec
await run_spec(backend, copy.deepcopy(EXAMPLE_SPEC))
assert await run_critique(backend, list(TOPO_FOCUSES)) == []
everything = await run_critique(backend, None)
assert not [i for i in everything if i.focus.startswith("topo_")]
async def test_an_arch_plan_from_spec_gets_no_topo_issue(backend):
from engineering.arch.spec import EXAMPLE_SPEC, draw_plan_from_spec
await draw_plan_from_spec(backend, copy.deepcopy(EXAMPLE_SPEC))
assert await run_critique(backend, list(TOPO_FOCUSES)) == []
async def test_a_drawing_without_a_network_layer_is_never_snapshotted(backend, monkeypatch):
import engineering.understand.snapshot as snapshot
async def refuse(*_args, **_kwargs):
raise AssertionError("the snapshot was taken")
monkeypatch.setattr(snapshot, "take_snapshot", refuse)
await backend.entity_create_line(0, 0, 100, 0, layer="GEOMETRY")
assert await build_index(backend) == {"layers": [], "findings": None, "error": None}
async def test_a_snapshot_that_fails_is_reported_not_passed(backend, pipe_layer, monkeypatch):
import engineering.understand.snapshot as snapshot
async def broken(*_args, **_kwargs):
raise RuntimeError("export failed")
monkeypatch.setattr(snapshot, "take_snapshot", broken)
await _lines(backend, pipe_layer, (0, 0, 1000, 0))
(issue,) = await _issues(backend, "topo_near_miss")
assert issue.severity == "info" and "export failed" in issue.message
# -- the dispatch ----------------------------------------------------------------------
async def test_the_three_focuses_share_one_index_per_run(backend, pipe_layer):
await _lines(backend, pipe_layer, (0, 0, 1000, 0))
shared: dict = {}
for focus in TOPO_FOCUSES:
await TOPO_DISPATCH[focus](backend, shared)
assert list(shared) == ["topo_index"]
async def test_run_critique_reaches_the_topo_focuses(backend, pipe_layer):
await _lines(backend, pipe_layer, (0, 0, 1000, 0), (1003, 0, 2003, 0))
(issue,) = await run_critique(backend, ["topo_near_miss"])
assert issue.focus == "topo_near_miss" and issue.severity == "error"
+317
View File
@@ -0,0 +1,317 @@
"""The topology engine: ends, joins, near misses, crossings - by hand-computed geometry.
Every record is built by hand, so every coordinate in an assertion is the
answer. The network layer is called PIPE here; which layers are network layers
by default is ``network_layers``' job and is tested on the synthetic plant.
"""
from __future__ import annotations
import pytest
from engineering.understand.snapshot import EntityRecord, Snapshot
from engineering.understand.topology import (
Piece,
cap_findings,
entity_pieces,
network_layers,
topology_findings,
)
from engineering.understand.vocab import classify_layer
from tests.fixtures.plant_pair import build_plant_pair
L = "PIPE"
def rec(handle, type_, layer=L, points=(), **kw):
space = kw.pop("space", "Model")
return EntityRecord(
handle=handle, type=type_, layer=layer, space=space, points=tuple(points), **kw
)
def line(handle, x1, y1, x2, y2, layer=L, space="Model"):
return rec(handle, "LINE", layer, ((x1, y1), (x2, y2)), space=space)
def snap(*records):
return Snapshot(
source="test",
insunits=4,
extmin=None,
extmax=None,
layers={r.layer: {"color": 7, "linetype": "Continuous"} for r in records},
layouts=("Layout1",),
records=tuple(records),
)
def run(*records, gap=5.0, layers=(L,)):
return topology_findings(snap(*records), layers=list(layers), gap=gap)
def ends(rows):
return {(row["handle"], row["end"]) for row in rows}
# -- pieces ----------------------------------------------------------------------------
def test_a_bulge_of_one_is_the_semicircle_below_its_chord():
# bulge +1 from (0,0) to (200,0): counter-clockwise, centre (100,0), radius 100,
# through (100,-100)
pieces, entity_ends = entity_pieces(
rec("PL", "LWPOLYLINE", L, ((0.0, 0.0), (200.0, 0.0)), bulges=(1.0, 0.0))
)
(arc,) = pieces
assert arc.centre == (pytest.approx(100.0), pytest.approx(0.0, abs=1e-9))
assert arc.radius == pytest.approx(100.0)
foot = arc.nearest((100.0, -150.0))
assert foot == (pytest.approx(100.0), pytest.approx(-100.0))
assert entity_ends == ((0.0, 0.0), (200.0, 0.0))
def test_a_closed_polyline_has_no_ends_and_a_closing_piece():
pieces, entity_ends = entity_pieces(
rec("SQ", "LWPOLYLINE", L, ((0, 0), (10, 0), (10, 10), (0, 10)), closed=True)
)
assert entity_ends is None and len(pieces) == 4
assert (pieces[3].a, pieces[3].b) == ((0.0, 10.0), (0.0, 0.0))
def test_a_straight_piece_nearest_point_is_clamped_to_its_ends():
piece = Piece("A", L, 0, (0.0, 0.0), (10.0, 0.0))
assert piece.nearest((5.0, 3.0)) == (5.0, 0.0)
assert piece.nearest((-4.0, 3.0)) == (0.0, 0.0)
# -- dangling and joined ---------------------------------------------------------------
def test_a_lone_line_dangles_at_both_ends():
result = run(line("A", 0, 0, 1000, 0))
assert ends(result["dangling"]) == {("A", "start"), ("A", "end")}
assert result["near_miss"] == [] and result["crossing"] == []
def test_a_t_junction_joins_the_stem():
result = run(line("A", 0, 0, 1000, 0), line("B", 500, 0, 500, 500))
assert ends(result["dangling"]) == {("A", "start"), ("A", "end"), ("B", "end")}
def test_a_closed_square_of_lines_has_no_finding():
square = [
line("A", 0, 0, 1000, 0),
line("B", 1000, 0, 1000, 1000),
line("C", 1000, 1000, 0, 1000),
line("D", 0, 1000, 0, 0),
]
result = run(*square)
assert (result["dangling"], result["near_miss"], result["crossing"]) == ([], [], [])
def test_an_end_on_an_arc_and_on_an_arc_body_is_joined():
# ARC centre (0,0) r 100 from 0 to 90 degrees: ends (100,0) and (0,100)
arc = rec("ARC1", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(0.0, 90.0))
on_end = line("L1", 100, 0, 500, 0)
on_body = line("L2", 70.71067811865476, 70.71067811865476, 300, 300)
result = run(arc, on_end, on_body)
assert ends(result["dangling"]) == {("ARC1", "end"), ("L1", "end"), ("L2", "end")}
def test_an_end_on_a_bulged_segment_is_joined():
arc = rec("PL", "LWPOLYLINE", L, ((0.0, 0.0), (200.0, 0.0)), bulges=(1.0, 0.0))
result = run(arc, line("L", 100, -100, 100, -500))
assert ends(result["dangling"]) == {("PL", "start"), ("PL", "end"), ("L", "end")}
def test_an_end_inside_an_insert_box_is_attached():
valve = rec("V1", "INSERT", "VALVES", ((1000, 0),), block="VALVE", bbox=(995, -5, 1005, 5))
result = run(line("A", 0, 0, 995, 0), valve)
assert ends(result["dangling"]) == {("A", "start")}
def test_an_end_on_a_curve_of_another_layer_is_attached():
tank = rec(
"TK",
"LWPOLYLINE",
"EQUIPMENT",
((1000, -500), (2000, -500), (2000, 500), (1000, 500)),
closed=True,
)
result = run(line("A", 0, 0, 1000, 0), tank)
assert ends(result["dangling"]) == {("A", "start")}
def test_model_space_and_a_layout_never_join():
result = run(line("A", 0, 0, 100, 0), line("B", 100, 0, 200, 0, space="Layout1"))
assert ends(result["dangling"]) == {("A", "start"), ("A", "end"), ("B", "start"), ("B", "end")}
# -- lines drawn twice -----------------------------------------------------------------
def test_a_pipe_drawn_twice_still_dangles_at_all_four_ends():
result = run(line("A", 0, 0, 1000, 0), line("A2", 0, 0, 1000, 0))
assert ends(result["dangling"]) == {
("A", "start"),
("A", "end"),
("A2", "start"),
("A2", "end"),
}
assert result["near_miss"] == [] and result["crossing"] == []
def test_a_line_over_a_polyline_joins_only_where_the_polyline_turns():
polyline = rec("PL", "LWPOLYLINE", L, ((0, 0), (1000, 0), (1000, 1000)))
result = run(polyline, line("OV", 0, 0, 1000, 0))
assert ends(result["dangling"]) == {("OV", "start"), ("PL", "end"), ("PL", "start")}
def test_an_arc_drawn_twice_still_dangles_at_all_four_ends():
# the arc form of a pipe drawn twice: a bend drawn twice must not join itself
first = rec("A1", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(0.0, 90.0))
second = rec("A2", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(0.0, 90.0))
result = run(first, second)
assert ends(result["dangling"]) == {
("A1", "start"),
("A1", "end"),
("A2", "start"),
("A2", "end"),
}
assert result["near_miss"] == []
def test_two_arcs_of_one_circle_butted_end_to_end_join():
# 0-90 then 90-180 on the same circle: continuing the other way is a join
first = rec("A1", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(0.0, 90.0))
second = rec("A2", "ARC", L, ((0.0, 0.0),), radius=100.0, angles=(90.0, 180.0))
assert ends(run(first, second)["dangling"]) == {("A1", "start"), ("A2", "end")}
def test_a_bulge_of_minus_one_is_the_semicircle_above_its_chord():
((arc,), _ends) = entity_pieces(
rec("PL", "LWPOLYLINE", L, ((0.0, 0.0), (200.0, 0.0)), bulges=(-1.0, 0.0))
)
assert arc.radius == pytest.approx(100.0)
assert arc.nearest((100.0, 150.0)) == (pytest.approx(100.0), pytest.approx(100.0))
def test_a_side_drawn_twice_inside_a_closed_loop_is_harmless():
square = [
line("A", 0, 0, 1000, 0),
line("A2", 0, 0, 1000, 0),
line("B", 1000, 0, 1000, 1000),
line("C", 1000, 1000, 0, 1000),
line("D", 0, 1000, 0, 0),
]
result = run(*square)
assert (result["dangling"], result["near_miss"], result["crossing"]) == ([], [], [])
# -- near misses -----------------------------------------------------------------------
def test_two_ends_3_apart_are_one_end_end_near_miss():
result = run(line("A", 0, 0, 1000, 0), line("B", 1003, 0, 2000, 0))
(row,) = result["near_miss"]
assert row["handles"] == ["A", "B"] and row["kind"] == "end_end"
assert row["gap"] == pytest.approx(3.0)
assert (row["at"], row["to"]) == ([1000.0, 0.0], [1003.0, 0.0])
assert ends(result["dangling"]) == {("A", "start"), ("B", "end")}
def test_an_end_4_above_a_line_is_an_end_segment_near_miss():
result = run(line("A", 0, 0, 1000, 0), line("B", 500, 4, 500, 500))
(row,) = result["near_miss"]
assert row["handles"] == ["B", "A"] and row["kind"] == "end_segment"
assert row["gap"] == pytest.approx(4.0) and row["to"] == [500.0, 0.0]
def test_an_end_beyond_the_gap_dangles_instead():
result = run(line("A", 0, 0, 1000, 0), line("B", 500, 6, 500, 500))
assert result["near_miss"] == []
assert ("B", "start") in ends(result["dangling"])
def test_a_polyline_that_nearly_closes_on_itself_is_one_near_miss():
ring = rec("RING", "LWPOLYLINE", L, ((0, 0), (1000, 0), (1000, 1000), (0, 1000), (0, 3)))
result = run(ring)
(row,) = result["near_miss"]
assert row["handles"] == ["RING", "RING"] and row["kind"] == "end_end"
assert row["gap"] == pytest.approx(3.0)
assert result["dangling"] == []
# -- crossings -------------------------------------------------------------------------
def test_two_lines_of_one_layer_crossing_mid_span_are_a_crossing():
result = run(line("A", 0, 0, 1000, 0), line("B", 500, -500, 500, 500))
assert result["crossing"] == [
{"handles": ["A", "B"], "layer": L, "at": [500.0, 0.0], "space": "Model"}
]
def test_two_layers_crossing_and_a_t_junction_are_not_crossings():
other = line("B", 500, -500, 500, 500, layer="CABLE")
assert run(line("A", 0, 0, 1000, 0), other, layers=(L, "CABLE"))["crossing"] == []
assert run(line("A", 0, 0, 1000, 0), line("B", 500, 0, 500, 500))["crossing"] == []
def test_a_bow_tie_polyline_crosses_itself():
bow = rec("BOW", "LWPOLYLINE", L, ((0, 0), (100, 100), (100, 0), (0, 100)))
(row,) = run(bow)["crossing"]
assert row["handles"] == ["BOW", "BOW"] and row["at"] == [50.0, 50.0]
# -- defaults and refusals -------------------------------------------------------------
def test_the_default_gap_is_0_2_percent_of_the_network_diagonal():
# box 0..3000 x 0..4000: diagonal 5000, gap 10
result = topology_findings(snap(line("A", 0, 0, 3000, 0), line("B", 0, 0, 0, 4000)), layers=[L])
assert result["gap"] == pytest.approx(10.0)
assert result["stats"] == {"entities": 2, "ends": 4, "skipped": {}}
def test_an_arc_without_a_centre_is_skipped_and_counted():
result = run(line("A", 0, 0, 10, 0), rec("ARC9", "ARC", L, (), radius=5.0, angles=(0, 90)))
assert result["stats"]["skipped"] == {"ARC": 1}
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"layers": []}, r"^layers: name at least one layer to check"),
({"layers": [L], "tol": 0}, r"^tol must be a positive number; got 0"),
({"layers": [L], "gap": -1}, r"^gap must be a positive number; got -1"),
({"layers": [L], "gap": 0.005}, r"^gap \(0\.005\) must exceed tol \(0\.01\)"),
],
)
def test_refusals_come_before_any_work(kwargs, message):
with pytest.raises(ValueError, match=message):
topology_findings(snap(line("A", 0, 0, 1, 0)), **kwargs)
def test_cap_findings_counts_everything_and_cuts_the_lists():
lines = [line(f"L{i}", 0, 1000 * i, 100, 1000 * i) for i in range(3)]
capped = cap_findings(run(*lines), limit=2)
assert capped["counts"] == {"dangling": 6, "near_miss": 0, "crossing": 0}
assert capped["truncated"] == {"dangling": 4, "near_miss": 0, "crossing": 0}
assert len(capped["dangling"]) == 2
# -- the default layers on the synthetic plant -----------------------------------------
def test_the_plant_piping_layers_are_network_layers(tmp_path):
from engineering.understand.snapshot import read_snapshot
truth = build_plant_pair(tmp_path)
pid = read_snapshot(truth["pid"])
found = {row["layer"] for row in network_layers(pid)}
for layer, service in truth["layer_services"].items():
if layer in pid.layers and service in ("product", "cip_supply", "cip_return"):
assert layer in found, (layer, classify_layer(layer))
assert all(row["confidence"] >= 0.9 for row in network_layers(pid))
+101
View File
@@ -0,0 +1,101 @@
"""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