Files
autocad-mcp-pro/backends/ocs.py
T
Umut CANandClaude Opus 5 b03a7615b1 release: v1.5.0 — discovery layer, honesty debt closed, Wave A
Tool count 131 -> 154; 474 -> 1242 collected tests.

The release exists for two measured reasons. A 149-tool catalog costs a client
~40,300 tokens before it has asked for anything, and a drafter searching the way
drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock
index, because those words were simply not in the data.

Discovery and batching
- DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool,
  ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on
  top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised.
- cad_batch runs a step list in one round trip; fields= projects result-heavy
  responses. The `core` profile is gone -- discovery replaced it.

Honesty debt (Tier 0)
- OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and
  rotated geometry reported coordinates that were quietly wrong. backends/ocs.py
  translates at the backend boundary; every tool coordinate is WCS on both
  engines, and a tilted plane refuses rather than answering with a number that
  looks fine.
- drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never
  repaired. analysis_measure_area shoelaced points the caller typed in.
- ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a
  server whose document lock is a single asyncio.Lock.

Architecture
- The 1854-line backend ABC is 19 contract modules; @capability(key, reason=)
  makes optional methods declarative and a gate fails on undeclared holes.
- 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch.
- Wave A: selection filters, boundary tracing, hatch depth, annotation objects,
  measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was
  cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann
  substitute loses 28.2% of the area on a square with one semicircular edge.

Verified against a live AutoCAD 2026, which found four COM defects no amount of
reading would have caught -- AcadPViewport has no ViewCenter, and the line that
set it had been swallowed by a bare except since v1.4.

Benchmark v3 (15 tasks) and what it caught
- v2 scored this server 10/10, which carried no information: every task in it
  exercised something the server was built around. Five tasks were added because
  they can fail, and three did while being written -- a hatch could not be
  measured though the refusal listed HATCH as measurable, the boundary tools and
  the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a
  circle stored as two bulged vertices measured 0.0.
- Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and
  were never asked these five questions; the chart shows those rows `not run`.
- Correctness A/B: 21/24 -> 24/24, zero regressions.

Performance, stated plainly
The published v1.4 perf report was recorded on CPython 3.14 and the new one on
3.11.15, so the apparent 2-6x speedup is the interpreter, not this release.
Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22%
slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores
v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is
documented, and winning it back is on the 1.6 roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:45:20 +03:00

101 lines
4.0 KiB
Python

"""OCS (Object Coordinate System) to WCS translation, shared by both backends.
DXF does not store 2D geometry in world coordinates. CIRCLE, ARC, LWPOLYLINE,
TEXT, INSERT and a dimension's text position live in a per-entity frame derived
from the entity's ``extrusion`` vector by the Arbitrary Axis Algorithm. Mirroring
flips extrusion to ``(0, 0, -1)`` and leaves the stored x *unnegated*, so a raw
``dxf.center`` read reports a point the entity is not at — by 60 mm in the
measured case, while the same response's bounding box (which ezdxf computes
through the renderer, in WCS) said otherwise.
The rule this module exists to enforce: **everything crossing the MCP boundary is
WCS.** OCS never leaves the backends.
Both engines share this. ``ezdxf`` is a core dependency, not an extra, so the COM
backend may import it too — and should, rather than hand-rolling the Arbitrary
Axis Algorithm a second time. The functions take a raw extrusion/normal triple
rather than an entity, because a COM object exposes ``.Normal`` where an ezdxf
one exposes ``.dxf.extrusion``.
Not every entity that *carries* extrusion is affected: ezdxf makes ``ocs()`` a
pass-through for LINE, MTEXT, ELLIPSE, SPLINE and POINT, which are WCS-native.
Keying any fix off "has an extrusion attribute" breaks those.
"""
from __future__ import annotations
import math
from ezdxf.math import OCS, Vec3
_IDENTITY = (0.0, 0.0, 1.0)
def is_wcs_frame(extrusion) -> bool:
"""True when the entity's frame is the identity — nothing to translate."""
try:
return OCS(Vec3(extrusion)).uz.isclose(_IDENTITY)
except Exception:
return True # an unreadable extrusion is not evidence of a rotated frame
def is_flat_frame(extrusion) -> bool:
"""True when the entity's plane is parallel to WCS XY (extrusion is +/-Z).
Only these frames admit an exact 2D write: given a tilted plane, an ``(x, y)``
pair does not determine a point in it, so writing one would displace the
entity out of its own plane by an amount nobody supplied.
"""
try:
uz = OCS(Vec3(extrusion)).uz
except Exception:
return True
return abs(uz.x) < 1e-12 and abs(uz.y) < 1e-12
def to_wcs_2d(extrusion, x, y, z=0.0) -> list[float]:
"""OCS ``(x, y, z)`` to WCS ``[x, y]``.
``z`` is ``dxf.elevation`` for an LWPOLYLINE (whose vertices carry no z of
their own) and the point's own z otherwise.
"""
v = OCS(Vec3(extrusion)).to_wcs(Vec3(float(x), float(y), float(z)))
return [float(v.x), float(v.y)]
def from_wcs(extrusion, x, y, z=0.0) -> tuple[float, float, float]:
"""WCS ``(x, y, z)`` to OCS ``(x, y, z)`` — the inverse of :func:`to_wcs_2d`.
Needed on every write path. The read and write halves used to be *equally*
wrong, which made a read-then-write round trip an accidental no-op; fixing
reads alone would have converted that no-op into a real move.
"""
v = OCS(Vec3(extrusion)).from_wcs(Vec3(float(x), float(y), float(z)))
return (float(v.x), float(v.y), float(v.z))
def wcs_arc_angles(extrusion, center_ocs, radius, start_deg, end_deg) -> tuple[float, float]:
"""OCS sweep angles to CCW-in-WCS angles about the WCS centre.
Computed by mapping the two endpoints through the frame rather than by
arithmetic on the angles, so it stays right for any frame. A left-handed
frame (``uz.z < 0``, which is what a mirror produces) reverses the direction
of the sweep, so the endpoints swap roles.
"""
ocs = OCS(Vec3(extrusion))
c0 = Vec3(center_ocs)
centre = ocs.to_wcs(c0)
angles = []
for angle in (float(start_deg), float(end_deg)):
point = ocs.to_wcs(Vec3.from_deg_angle(angle, abs(float(radius))) + c0)
angles.append(math.degrees(math.atan2(point.y - centre.y, point.x - centre.x)) % 360.0)
if ocs.uz.z < 0:
angles.reverse()
return angles[0], angles[1]
def plane_normal(extrusion) -> list[float]:
"""The entity's plane normal, for reporting a frame that xy cannot express."""
uz = OCS(Vec3(extrusion)).uz
return [float(uz.x), float(uz.y), float(uz.z)]