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:
co-authored by
Claude Opus 5.5
parent
30f886c240
commit
2b74bf20a6
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **v1.5 release snapshot:** 242 tools · 8 resources · 5 prompt templates · 4516 collected tests.
|
> **v1.5 release snapshot:** 243 tools · 8 resources · 5 prompt templates · 4516 collected tests.
|
||||||
> 242 is the **registered** count; a default install advertises 237 over `tools/list`,
|
> 243 is the **registered** count; a default install advertises 238 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
## Why this exists
|
## 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) |
|
| 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 |
|
| 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -2478,6 +2478,18 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"revizyonlar arasi degisiklik",
|
"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",
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 242,
|
"tools": 243,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 26
|
"groups": 26
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"analysis": 13,
|
"analysis": 14,
|
||||||
"architecture": 12,
|
"architecture": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 9,
|
"blocks": 9,
|
||||||
@@ -1175,6 +1175,20 @@
|
|||||||
"destructiveHint": false
|
"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",
|
"name": "drawing_undo",
|
||||||
"group": "drawing",
|
"group": "drawing",
|
||||||
|
|||||||
@@ -420,6 +420,10 @@ from .arch.critique import ARCH_DISPATCH # noqa: E402 (same reason: after the
|
|||||||
|
|
||||||
_FOCUS_DISPATCH.update(ARCH_DISPATCH)
|
_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(
|
async def run_critique(
|
||||||
backend: AutoCADBackend,
|
backend: AutoCADBackend,
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ CritiqueFocus = Literal[
|
|||||||
"arch_room_unlabelled",
|
"arch_room_unlabelled",
|
||||||
"arch_wall_gap",
|
"arch_wall_gap",
|
||||||
"arch_opening_clash",
|
"arch_opening_clash",
|
||||||
|
"topo_dangling_endpoint",
|
||||||
|
"topo_near_miss",
|
||||||
|
"topo_interior_crossing",
|
||||||
]
|
]
|
||||||
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
|
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
|
||||||
|
|
||||||
@@ -62,6 +65,9 @@ ALL_CRITIQUE_FOCUSES: tuple[CritiqueFocus, ...] = (
|
|||||||
"arch_room_unlabelled",
|
"arch_room_unlabelled",
|
||||||
"arch_wall_gap",
|
"arch_wall_gap",
|
||||||
"arch_opening_clash",
|
"arch_opening_clash",
|
||||||
|
"topo_dangling_endpoint",
|
||||||
|
"topo_near_miss",
|
||||||
|
"topo_interior_crossing",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -5746,7 +5746,9 @@ async def drawing_finalize(
|
|||||||
mech_view_misaligned, mech_duplicate_dimension, mech_thread_unrepresented,
|
mech_view_misaligned, mech_duplicate_dimension, mech_thread_unrepresented,
|
||||||
mech_bom_balloon_mismatch — silent on a sheet with no mechanical part, plus the
|
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 —
|
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.
|
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
|
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_duplicate_dimension, mech_thread_unrepresented, "
|
||||||
"mech_bom_balloon_mismatch (silent on a drawing with no mechanical part), and "
|
"mech_bom_balloon_mismatch (silent on a drawing with no mechanical part), and "
|
||||||
"the architectural focuses arch_room_unlabelled, arch_wall_gap, "
|
"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 = run all.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = 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
|
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 ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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():
|
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
|
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
|
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.
|
pre-track-F value.
|
||||||
|
|
||||||
Track H group D opened its copy of SECTION 26 (Understanding & QA) with
|
Track H group D opened its copy of SECTION 26 (Understanding & QA) with
|
||||||
`drawing_diff`, tagged `analysis` and `query`; `analysis` outranks `query`
|
`drawing_diff` and `drawing_topology_check`, both tagged `analysis` and
|
||||||
in `_GROUP_TAG_PRIORITY`, so it files under `analysis` (12 -> 13). This
|
`query`; `analysis` outranks `query` in `_GROUP_TAG_PRIORITY`, so both file
|
||||||
snapshot is branch-local: the track H merge recomputes it once the
|
under `analysis` (12 -> 14). This snapshot is branch-local: the track H
|
||||||
understand, network and diff branches have landed.
|
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()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
"analysis": 13,
|
"analysis": 14,
|
||||||
"architecture": 12,
|
"architecture": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 9,
|
"blocks": 9,
|
||||||
@@ -627,4 +628,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 242
|
assert sum(sizes.values()) == 243
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user