merge: track H group D
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:
@@ -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 (2 tools) ────────────────────────────────
|
||||
# ── SECTION 26: Understanding & QA (4 tools) ────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -10713,6 +10717,317 @@ async def drawing_scale_check(
|
||||
raise ToolError(str(exc)) from exc
|
||||
|
||||
|
||||
async def _diff_revision(backend, path: str | None, *, tol: float, role: str):
|
||||
"""One revision for ``drawing_diff``: its records and, where they can be
|
||||
read, its block signatures (None otherwise).
|
||||
|
||||
A DXF path is read once, here, for both. The current document goes through
|
||||
``take_snapshot``; its block table is read only on the headless engine,
|
||||
under the backend's own lock. Any other path (a DWG) goes through the
|
||||
snapshot too, which opens it read-only on the live engine.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from engineering.understand.diff import block_signatures, read_revision
|
||||
from engineering.understand.snapshot import take_snapshot
|
||||
|
||||
if path is None:
|
||||
snap = await take_snapshot(backend, None)
|
||||
blocks = None
|
||||
run = getattr(backend, "_async", None)
|
||||
if backend.name == "ezdxf" and run is not None:
|
||||
blocks = await run(lambda: block_signatures(backend._doc, tol=tol))
|
||||
return snap, blocks
|
||||
validated = validate_path(path)
|
||||
if not validated.is_file():
|
||||
raise ToolError(f"{role}: {validated} does not exist or is not a file")
|
||||
if validated.suffix.lower() == ".dxf":
|
||||
try:
|
||||
return await asyncio.to_thread(read_revision, str(validated), tol=tol)
|
||||
except ValueError as exc:
|
||||
raise ToolError(f"{role}: {exc}") from exc
|
||||
try:
|
||||
return await take_snapshot(backend, str(validated)), None
|
||||
except ValueError as exc: # an unreadable suffix; the dwg refusal is not a ValueError
|
||||
raise ToolError(f"{role}: {exc}") from exc
|
||||
|
||||
|
||||
async def _diff_markup(backend, result: dict, new_snap, *, rev: str, description: str) -> dict:
|
||||
"""Revision clouds around every model-space cluster of change, filed under
|
||||
``rev`` through ``engineering.sheet.revision.add_revision`` - the only
|
||||
write ``drawing_diff`` ever makes."""
|
||||
from engineering.sheet.bom import LIST_WIDTH, ROW_HEIGHT
|
||||
from engineering.sheet.revision import add_revision
|
||||
from engineering.understand.diff import robust_box
|
||||
|
||||
clusters = [c for c in result["clusters"] if c["space"] == "Model"]
|
||||
paper = len(result["clusters"]) - len(clusters)
|
||||
if not clusters:
|
||||
return {
|
||||
"created": False,
|
||||
"clouds": [],
|
||||
"reason": "no change in model space to cloud",
|
||||
"paper_space_clusters": paper,
|
||||
}
|
||||
boxes = [c["box"] for c in clusters]
|
||||
shortest = min(min(b[2] - b[0], b[3] - b[1]) for b in boxes)
|
||||
box = robust_box(new_snap) or (
|
||||
min(b[0] for b in boxes),
|
||||
min(b[1] for b in boxes),
|
||||
max(b[2] for b in boxes),
|
||||
max(b[3] for b in boxes),
|
||||
)
|
||||
summary = result["summary"]
|
||||
text = description or (
|
||||
f"drawing_diff: {summary['changed']} changed, {summary['added']} added, "
|
||||
f"{summary['removed']} removed"
|
||||
)
|
||||
drawn = await add_revision(
|
||||
backend,
|
||||
rev=rev,
|
||||
description=text,
|
||||
clouds=boxes,
|
||||
at=(box[2] - LIST_WIDTH, box[3] + 3.0 * ROW_HEIGHT),
|
||||
cloud_segment=shortest / 4.0,
|
||||
)
|
||||
drawn["paper_space_clusters"] = paper
|
||||
return drawn
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Compare two revisions of a drawing: what moved, changed, was added or removed.",
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Drawing: Diff Two Revisions", "destructiveHint": False},
|
||||
tags={"analysis", "query"},
|
||||
)
|
||||
async def drawing_diff(
|
||||
old_path: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="The OLDER revision: a DXF path (a DWG only on the live engine, which "
|
||||
"exports it read-only)."
|
||||
),
|
||||
],
|
||||
new_path: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="The NEWER revision: a DXF path, or omitted for the current document.",
|
||||
),
|
||||
] = None,
|
||||
tol: Annotated[
|
||||
float,
|
||||
Field(
|
||||
default=0.01,
|
||||
gt=0,
|
||||
description="Geometry is compared rounded to this many drawing units.",
|
||||
),
|
||||
] = 0.01,
|
||||
move_tol: Annotated[
|
||||
float | None,
|
||||
Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
description="An unmatched entity is paired with the nearest one of its type and layer "
|
||||
"within this distance and reported as changed; default 2 % of the drawing's robust "
|
||||
"diagonal.",
|
||||
),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=200, ge=1, le=5000, description="Rows kept per list (changed, added, removed)."
|
||||
),
|
||||
] = 200,
|
||||
markup: Annotated[
|
||||
bool,
|
||||
Field(
|
||||
default=False,
|
||||
description="Draw revision clouds around every cluster of change on the current "
|
||||
"document (the newer revision) and file them under `rev`.",
|
||||
),
|
||||
] = False,
|
||||
rev: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="",
|
||||
description="Revision code for markup=True: one to three capitals or digits.",
|
||||
),
|
||||
] = "",
|
||||
description: Annotated[
|
||||
str,
|
||||
Field(default="", description="Revision-row text for markup=True; default: the counts."),
|
||||
] = "",
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Compare two revisions of one drawing by geometric signature (spec §10).
|
||||
|
||||
Entities are matched exactly first (type, layer, geometry rounded to `tol`,
|
||||
text, block, attributes - handles do not matter), then by handle while
|
||||
handles look stable, then by type + layer + nearest position within
|
||||
`move_tol`; what is left is added or removed. The report counts changes by
|
||||
layer and type, lists each changed entity with what changed (moved by, text
|
||||
from -> to, attribute from -> to, radius from -> to, ...), compares the
|
||||
block definitions when both revisions are DXF files or the headless current
|
||||
document, and groups the changes into clusters.
|
||||
|
||||
Read-only unless `markup=True`: then revision clouds are drawn around the
|
||||
model-space clusters on the current document, which must be the newer
|
||||
revision, and filed as one revision row - the only write this tool makes.
|
||||
|
||||
Refused before anything is read: `markup=True` with `new_path` (the clouds go
|
||||
on the current document), `markup=True` without a valid `rev`, `markup=True`
|
||||
on an engine without the revcloud capability (the live engine -
|
||||
`capability: "revcloud"`), a path that does not exist, a DXF over
|
||||
MAX_DXF_BYTES (the size and the variable named), a file that is not a
|
||||
readable DXF, and a non-positive `tol` or `move_tol`.
|
||||
"""
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from engineering.sheet.revision import REV_RE
|
||||
from engineering.understand.diff import block_changes, cap_report, diff_snapshots
|
||||
|
||||
backend = _backend(ctx)
|
||||
if markup:
|
||||
if new_path:
|
||||
raise ToolError(
|
||||
"markup draws revision clouds on the current document, which must be the newer "
|
||||
"revision: open it with drawing_open and call again without new_path"
|
||||
)
|
||||
if not REV_RE.fullmatch(rev.strip().upper()):
|
||||
raise ToolError(
|
||||
f"markup needs `rev`, the revision code the clouds are filed under - one to "
|
||||
f"three capitals or digits (A, B, 01); got {rev!r}"
|
||||
)
|
||||
feature = backend.capabilities().features.get("revcloud")
|
||||
if feature is None or not feature.supported:
|
||||
raise UnsupportedCapabilityError(
|
||||
"revcloud",
|
||||
f"drawing_diff(markup=True): the {backend.name} backend cannot draw revision "
|
||||
"clouds (no ActiveX member; REVCLOUD is command-line only). Run the diff "
|
||||
"without markup, or on the ezdxf engine.",
|
||||
)
|
||||
await ctx.info(f"drawing_diff: {old_path} -> {new_path or 'the current document'}")
|
||||
old_snap, old_blocks = await _diff_revision(backend, old_path, tol=tol, role="old_path")
|
||||
new_snap, new_blocks = await _diff_revision(backend, new_path, tol=tol, role="new_path")
|
||||
try:
|
||||
result = diff_snapshots(old_snap, new_snap, tol=tol, move_tol=move_tol)
|
||||
except ValueError as exc:
|
||||
raise ToolError(str(exc)) from exc
|
||||
result["old"] = old_snap.source
|
||||
result["new"] = new_snap.source
|
||||
result["blocks"] = block_changes(old_blocks, new_blocks)
|
||||
report = cap_report(result, limit)
|
||||
if markup:
|
||||
report["markup"] = await _diff_markup(
|
||||
backend, result, new_snap, rev=rev.strip().upper(), description=description
|
||||
)
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user