fix(correctness-bundle): I16 correctness fixes + dead code removal + ruff clean

N1: COM hatch non-associative (AddHatch associativity=False)
N2: ezdxf refuses DWG saves with clear error; server.py derives fmt from extension
R19: dimension_aligned dist→distance; dimension_angular correct add_angular_dim_2l args (base/line1/line2/location)
R9: polar array 360° no-duplicate (divisor=n when fill_angle%360<ε)
R13: entity_offset side_x/side_y honored in ezdxf via cross-product (LINE) and distance-to-center (CIRCLE)
R18: drawing_new bootstrap failure surfaces status="degraded" instead of swallowing silently
Dead code: engineering/section.py, gear.generate_tooth_profile, CommandResult, _ACI_NAMES, set_layer_active, ensure_linetypes, ComBackend._batch_mode instance attr, engineering/__init__ build-seam comment
Ruff: 38 auto-fixed + 5 manual → codebase is now ruff-clean; CI lint is now blocking

184 tests pass.
This commit is contained in:
Umutcan Edizsalan
2026-06-18 11:34:30 +03:00
parent 702af0d6e1
commit 0c7f64ceb6
51 changed files with 2494 additions and 2865 deletions
@@ -1,98 +0,0 @@
---
name: autocad-mcp-premium
description: Use when working with AutoCAD MCP Pro tools and the user asks for technical drawings, P&ID, mechanical engineering output, ISO-conformant drawings, premium-quality CAD, or mentions trim/extend/fillet/chamfer, layer discipline, or "premium drawing". Apply the plan→draw→critique workflow strictly.
---
# AutoCAD MCP Premium Drafting Skill
Premium drafting discipline for the AutoCAD MCP Pro server. The goal: ISO-conformant
output that a real draftsman would sign. The path: a strict workflow + the corner
ops + meta-tool layer that ship with the server.
## When this skill applies
- The user asks for a **technical drawing**, **P&ID**, **mechanical part**, or
any drawing intended for production / fabrication / approval.
- The user mentions **TRIM, EXTEND, FILLET, CHAMFER**, **OSNAP**, **ISO 128/129/
13567**, **layer discipline**, or **premium drawing**.
- You are about to call `entity_create_*` for a deliverable drawing — **stop and
apply the workflow below first**.
## The 9-step Premium Workflow (non-negotiable)
```text
1. drawing_plan(intent, sheet_size, scale) # commit intent BEFORE drawing
2. drawing_apply_iso_layers("mech" | "pid" | "iso13567")
3. construction_xline(...) # scaffolding (optional)
4. entity_create_line / circle / arc / ... # geometry on engineering layers
5. entity_trim / entity_fillet / entity_chamfer # close corners explicitly
6. handles = entity_select_smart({type, layer, ...}) # don't memorise handles
7. dimension_auto(handles, style="chain"|"baseline"|"ordinate")
8. issues = drawing_critique(focus=None) # must be []
9. construction_clear()
10. drawing_finalize(save_path=..., screenshot_path=...)
```
If any of these is skipped, the drawing is **not premium** — it's a sketch.
## The 8 Non-Negotiable Rules (mirror of CLAUDE.md)
1. **Plan before draw** — `drawing_plan` first; PlanSpec is the contract.
2. **No coordinate guessing** — `point_from_snap(handle, snap, ref_x, ref_y)`.
3. **Layer discipline** — engineering layers only; CONSTRUCTION for scaffold.
4. **Lineweights are ISO 128** — bootstrap via `drawing_apply_iso_layers`.
5. **Corners are explicit** — `entity_trim` / `entity_fillet` / `entity_chamfer`.
6. **Dimensions via auto** — `dimension_auto`, not loops of `dimension_linear`.
7. **Critique then finalize** — `drawing_critique` must return `[]`.
8. **Meta-tools over raw** — never reimplement what a meta-tool already does.
## Tool Inventory (this skill assumes server has these registered)
| Group | Tools | Count |
|-------|-------|-------|
| Drawing | `drawing_*` (info/new/open/save/export/finalize/...) | 11 |
| Entity Create | `entity_create_line/circle/arc/polyline/text/...` | 12 |
| Dimensions | `dimension_linear/aligned/angular/radius/diameter` | 5 |
| Entity Modify | `entity_move/copy/rotate/scale/mirror/offset/delete/...` | 10 |
| **Corner Ops** ⭐ | `entity_trim` `entity_extend` `entity_fillet` `entity_chamfer` | **4** |
| Entity Query | `entity_get/list/delete_many` | 3 |
| Layer Mgmt | `layer_*`, `linetype_list/load` | 14 |
| Block Ops | `block_*` | 7 |
| Analysis | `analysis_*` | 8 |
| View | `view_*` | 4 |
| Transactions | `transaction_*` | 3 |
| System | `system_*` | 6 |
| Engineering | `gear_*`, `keyway_*`, `titleblock_*`, `drawing_finalize` | 7 |
| **Premium Meta** ⭐ | `drawing_plan` `drawing_critique` `point_from_snap` `construction_xline/clear` `drawing_apply_iso_layers` `dimension_auto` `entity_select_smart` | **8** |
## Reference Documents (read on demand)
- [`references/corner-ops.md`](references/corner-ops.md) — trim/extend/fillet/chamfer
semantics, decision tree (when to use which), corner-case table.
- [`references/meta-planning.md`](references/meta-planning.md) — `drawing_plan`,
`drawing_critique`, `point_from_snap` deep dive + PlanSpec / Issue schema.
- [`references/iso-standards.md`](references/iso-standards.md) — ISO 128 line
widths, ISO 13567 layer naming, ISO 129 dimension rules — printable tables.
- [`references/premium-workflow.md`](references/premium-workflow.md) — full
worked examples of the 9-step workflow with code blocks.
## Examples
- [`examples/premium-shaft.md`](examples/premium-shaft.md) — Shaft with keyway
(Anka-Makine reference part).
- [`examples/pid-simple-loop.md`](examples/pid-simple-loop.md) — P&ID flow
control loop with ISA-5.1 tags.
## Anti-patterns to refuse
- Calling `entity_create_*` before `drawing_plan` for a deliverable drawing.
- Computing endpoint/midpoint/intersection coordinates by hand instead of
`point_from_snap`.
- Drawing on layer 0 (or any non-engineering layer).
- Leaving the CONSTRUCTION layer populated at finalize time.
- Hand-rolling trim/extend math when `entity_trim` / `entity_extend` exists.
- Skipping `drawing_critique` and going straight to `drawing_finalize`.
When the user requests a premium drawing, **walk the 9 steps in order** and
narrate each call. If a critique returns non-empty issues, fix them in place
before proceeding to finalize.
@@ -1,107 +0,0 @@
# Example — Simple P&ID Flow Control Loop
ISA-5.1 tagged flow loop: pump P-101 → flow transmitter FT-101 → control valve
FCV-101 → vessel V-201. Drawn on A3 with the `pid` layer set.
## Layout
```text
┌────┐ ╭──╮ ┌────────┐
│P101├──────────────────┤FT├────────╳──────│ V-201 │
└────┘ PROCESS-MAIN ╰┬─╯ PROCESS │ │ │
│ signal │FCV │ │
↓ │101 │ │
(FIC101) │ └────────┘
controller │
─┴─ control output
```
## Full workflow
```python
# 1. PLAN
plan = await drawing_plan(
intent="Flow control loop 101: P-101 → FT-101 → FCV-101 → V-201",
sheet_size="A3",
scale=1.0,
layer_set_id="pid",
dim_style="ordinate", # P&ID typically minimal dim
notes=["ISA-5.1 tag conventions", "Pneumatic signal lines dashed"],
)
# 2. P&ID LAYERS
await drawing_apply_iso_layers("pid")
# 3. EQUIPMENT — pump (circle) and vessel (rectangle)
pump = await entity_create_circle(40, 60, 6, layer="PROCESS-EQUIPMENT")
await entity_create_text("P-101", 33, 50, height=3.0, layer="INSTRUMENT-TAG-TEXT")
vessel = await entity_create_rectangle(180, 40, 230, 80, layer="PROCESS-EQUIPMENT")
await entity_create_text("V-201", 195, 60, height=3.5, layer="INSTRUMENT-TAG-TEXT")
# 4. PROCESS LINE — main horizontal (heavy)
main_line_a = await entity_create_line(46, 60, 110, 60, layer="PROCESS-PIPING-MAIN")
main_line_b = await entity_create_line(110, 60, 150, 60, layer="PROCESS-PIPING-MAIN")
main_line_c = await entity_create_line(150, 60, 180, 60, layer="PROCESS-PIPING-MAIN")
# 5. INSTRUMENT BUBBLES — flow transmitter, control valve
ft = await entity_create_circle(110, 60, 4, layer="INSTRUMENT-SYMBOL")
await entity_create_text("FT", 107, 60.5, height=2.0, layer="INSTRUMENT-TAG-TEXT")
await entity_create_text("101", 106, 57.5, height=2.0, layer="INSTRUMENT-TAG-TEXT")
# control valve symbol = bowtie (2 triangles) — built from lines for now
fcv_top1 = await entity_create_line(150, 60, 154, 64, layer="PROCESS-VALVES")
fcv_top2 = await entity_create_line(150, 60, 154, 56, layer="PROCESS-VALVES")
fcv_bot1 = await entity_create_line(154, 64, 158, 60, layer="PROCESS-VALVES")
fcv_bot2 = await entity_create_line(154, 56, 158, 60, layer="PROCESS-VALVES")
await entity_create_text("FCV-101", 145, 70, height=2.5, layer="INSTRUMENT-TAG-TEXT")
# 6. SIGNAL LINES — FT bubble up to controller, then to FCV (dashed)
sig1 = await entity_create_line(110, 64, 110, 90, layer="INSTRUMENT-LINE-SIGNAL")
controller = await entity_create_circle(110, 95, 5, layer="INSTRUMENT-SYMBOL")
await entity_create_text("FIC", 107, 95.5, height=2.0, layer="INSTRUMENT-TAG-TEXT")
await entity_create_text("101", 106, 92.5, height=2.0, layer="INSTRUMENT-TAG-TEXT")
sig2 = await entity_create_line(110, 90, 154, 90, layer="INSTRUMENT-LINE-SIGNAL")
sig3 = await entity_create_line(154, 90, 154, 64, layer="INSTRUMENT-LINE-SIGNAL")
# 7. (no dimensions — P&ID is schematic, not dimensioned)
# 8. CRITIQUE — focus on what matters for P&ID
issues = await drawing_critique(focus=["layer_color", "iso128", "construction_left"])
assert issues == [], f"Premium gate failed: {issues}"
# 9. FINALIZE
await titleblock_apply_iso_a3(
title="P&ID — Flow Control Loop 101",
drawing_number="ANK-PID-2026-001",
revision="0",
scale="NTS",
drawn_by="UE",
company="Anka-Makine",
)
await drawing_finalize(
save_path=r"C:\work\anka\pid-loop-101.dwg",
screenshot_path=r"C:\work\anka\pid-loop-101.png",
)
```
## What this example demonstrates
- **`layer_set_id="pid"`** in `drawing_plan` triggers the P&ID layer bootstrap
(PROCESS-PIPING-MAIN, INSTRUMENT-SYMBOL, INSTRUMENT-LINE-SIGNAL, etc).
- **Layer-by-purpose**: process lines on PROCESS-PIPING-MAIN (heavy 0.7 mm),
signal lines on INSTRUMENT-LINE-SIGNAL (dashed 0.18), tags on
INSTRUMENT-TAG-TEXT.
- **ISA-5.1 tags** in text: `FT-101`, `FCV-101`, `FIC-101`, `P-101`, `V-201`.
- **No dimensions** — P&IDs are schematic. We pass a focused critique that
doesn't run dimension overlap checks.
- The same `drawing_finalize` infrastructure that handles mechanical drawings
works for P&IDs without modification.
## Where to grow
V2/V3 features that would polish this example:
- Symbol library blocks for pumps, valves, instruments (`block_insert`).
- ISO 14617 graphic-symbol catalog as MCP tools.
- Equipment-tag and line-tag auto-numbering.
@@ -1,125 +0,0 @@
# Example — Premium Shaft Drawing
A fully worked example: power-transmission shaft Ø30 × 200 mm with two keyway
seats (10 × 4 × 50 mm each per DIN 6885), drawn at 1:2 on A4.
## Goal
```text
50 100 50
┌──┴──┬───┴───┬──┴──┐
│ │ │ │ keyway both ends, M0.04 surface finish
└─────┴───────┴─────┘ bearing seat in middle (Ø30 h7)
────── 200 ──────
```
## Full workflow
```python
# 1. PLAN — commit intent first
plan = await drawing_plan(
intent="Power transmission shaft Ø30 x 200, 2× DIN 6885 keyway",
sheet_size="A4",
scale=0.5, # 1:2 (drawing is half real size)
layer_set_id="mech",
dim_style="chain",
notes=[
"Material: C45 normalized",
"Surface finish Ra 1.6 µm overall",
"Keyways: 10 x 4 x 50 mm per DIN 6885 A",
],
)
# 2. LAYERS — mech standard
await drawing_apply_iso_layers("mech")
# 3. CONSTRUCTION — centerline as reference
cl = await construction_xline(0, 0, 0) # horizontal centerline at y=0
# 4. GEOMETRY — front view (top half mirrored later)
top_edge = await entity_create_line(0, 15, 200, 15, layer="GEOMETRY")
right_edge = await entity_create_line(200, 15, 200, -15, layer="GEOMETRY")
bot_edge = await entity_create_line(200, -15, 0, -15, layer="GEOMETRY")
left_edge = await entity_create_line(0, -15, 0, 15, layer="GEOMETRY")
# 4b. KEYWAY LEFT — use the engineering primitive (per CLAUDE.md rule)
keyway_l = await keyway_draw_section(
bore_x=25, bore_y=0, bore_diameter=30,
keyway_width=10, keyway_depth=4,
)
# 4c. KEYWAY RIGHT — symmetric on the other end
keyway_r = await keyway_draw_section(
bore_x=175, bore_y=0, bore_diameter=30,
keyway_width=10, keyway_depth=4,
)
# 4d. Centerline (engineering layer, dashed)
center = await entity_create_line(-10, 0, 210, 0, layer="CENTER")
# 5. CORNERS — small chamfer at each end (1×45°)
await entity_chamfer(top_edge["handle"], left_edge["handle"], dist1=1)
await entity_chamfer(top_edge["handle"], right_edge["handle"], dist1=1)
await entity_chamfer(bot_edge["handle"], left_edge["handle"], dist1=1)
await entity_chamfer(bot_edge["handle"], right_edge["handle"], dist1=1)
# 6. SELECT geometry for dim
geometry = await entity_select_smart({
"type": "LINE",
"layer": "GEOMETRY",
"length_range": [40, 220], # exclude small chamfer lines
})
# 7. DIMENSIONS — chain dimensioning, 12 mm offset above the part
dims = await dimension_auto(
[e["handle"] for e in geometry],
style="chain",
offset=12.0,
)
# 8. CRITIQUE — must be empty before finalize
issues = await drawing_critique(focus=None)
if issues:
for i in issues:
print(f" [{i['severity']}] {i['focus']}: {i['message']}")
raise RuntimeError("Premium gate failed; fix before finalize")
# 9. CLEAR + TITLEBLOCK + FINALIZE
await construction_clear()
await titleblock_apply_iso_a3(
title="Transmission Shaft",
drawing_number="ANK-SH-2026-001",
revision="A",
scale="1:2",
material="C45 normalized",
drawn_by="UE",
company="Anka-Makine",
)
result = await drawing_finalize(
save_path=r"C:\work\anka\sh-2026-001.dwg",
screenshot_path=r"C:\work\anka\sh-2026-001.png",
expected={
"part_type": "shaft",
"must_have_keyway": True,
"must_have_bore": False, # solid shaft, not bored
},
)
```
## What this example demonstrates
- **Plan-first workflow** (rule 1)
- **Engineering primitives** for the keyway, not hand-drawn (rule 8 + CLAUDE.md
production drawing rule)
- **Construction line** for the centerline reference, cleared before finalize
- **Corner explicitness** via `entity_chamfer` (rule 5)
- **`entity_select_smart`** with `length_range` to filter out the tiny chamfer
lines (rule 8 — meta-tools over loops)
- **`dimension_auto`** with chain style (rule 6)
- **Critique gate** before finalize (rule 7)
- **`titleblock_apply_iso_a3`** + `drawing_finalize` with `expected` contract
The whole drawing comes out reproducible: the same call sequence generates an
identical DXF every time, suitable for `git diff` and ISO 9001 traceability.
@@ -1,77 +0,0 @@
# Corner Operations Reference
V1 supports **LINE+LINE** only. Other entity combinations (line+arc, line+circle,
polyline-segment) raise `RuntimeError` and will land in V2/V3.
## Decision tree
```text
┌────── two lines should meet at a sharp corner
│ → entity_trim (when one line overshoots)
│ → entity_extend (when one line falls short)
│
Need to close ─┼────── two lines should meet at a rounded corner (R>0)
a corner? │ → entity_fillet
│
└────── two lines should meet at an angled bevel
→ entity_chamfer
```
## `entity_trim(target_handle, cutter_handle, keep_x, keep_y)`
- `target` — the line being shortened.
- `cutter` — treated as an **infinite ray** (AutoCAD's "implied extend" trim).
- `keep_x/keep_y` — a point on the side of `target` you want to KEEP.
- Returns the (in-place modified) target's `EntityInfo`.
**Tip:** use `point_from_snap(target_handle, "mid")` or another snap to compute
`keep_x/keep_y` deterministically.
## `entity_extend(target_handle, boundary_handle, end_x=None, end_y=None)`
- Extends `target` until it hits `boundary` (treated as infinite).
- `end_x/end_y=None` → auto-pick the target endpoint nearest the boundary midpoint.
- Pass `end_x/end_y` explicitly to disambiguate when you need to extend a
specific endpoint.
## `entity_fillet(handle1, handle2, radius, trim=True)`
- Inserts a tangent **ARC** between the two lines.
- `radius=0` → corner-merge (no arc, only trim).
- `trim=False` → leave source lines untouched (rare; AutoCAD default is True).
- Returns the new ARC handle (or the first source line for `radius=0`).
## `entity_chamfer(handle1, handle2, dist1, dist2=None, trim=True)`
- Inserts a straight chamfer **LINE** between the two source lines.
- `dist2=None` → symmetric (`dist2 = dist1`); supply for asymmetric chamfers.
- Returns the new chamfer LINE handle.
## Corner-case table
| Vaka | Behaviour |
|------|-----------|
| Lines are parallel / anti-parallel | `RuntimeError("…parallel…")` |
| Same handle for both | `RuntimeError("…same…")` |
| One handle is not a LINE | `RuntimeError("…LINE+LINE only…")` |
| Intersection beyond segment ends | trim/fillet still work (implied extend) |
| Negative radius / zero distance | `RuntimeError` |
| Tiny gap (< 0.5 mm) between lines | flagged by `drawing_critique` as `untrimmed_corner` |
## Worked example — L-bracket inner corner R5
```python
l1 = await entity_create_line(0, 0, 100, 0) # bottom edge
l2 = await entity_create_line(100, 0, 100, 80) # right edge
arc = await entity_fillet(l1.handle, l2.handle, radius=5)
# arc.center == (95, 5); arc.radius == 5
# l1.end is now (95, 0); l2.start is now (100, 5)
```
## Why this matters
LLMs without these tools compute corner geometry by hand: they pick endpoints,
solve intersections, and write coordinates. That fails the moment a line is
slightly off, an angle is non-orthogonal, or the user changes a length. The
corner ops + `point_from_snap` move that math out of the LLM's head and into
deterministic library code.
@@ -1,109 +0,0 @@
# ISO Standards Reference
Distilled tables for the standards this skill enforces.
## ISO 128 — Line widths
Allowed lineweight set (mm), with 4:2:1 ratio rule:
| Width | Common use |
|-------|------------|
| 0.13 | Hatch, dim text shadow |
| 0.18 | Hidden, center, phantom, dim text |
| 0.25 | Default text, leader |
| 0.35 | Auxiliary visible (where contrast needed) |
| 0.50 | Visible solid edges (most common production weight) |
| 0.70 | Heavy solid (large-scale parts), P&ID main process |
| 1.00 | Border, key heavy outlines |
| 1.40 | Title borders |
| 2.00 | Sheet outer border |
**Rule:** every layer's lineweight must be one of these (or 0.0 = "Default" =
inherits from active style). Anything else fails `drawing_critique` focus
`iso128`.
## ISO 128 — Line types
| Type | Linetype name | Used for |
|------|---------------|----------|
| Continuous thick | `Continuous` (0.50+ mm) | Visible edges |
| Dashed thin | `HIDDEN` (0.18–0.25) | Hidden edges |
| Chain thin | `CENTER` (0.18) | Centerlines, axes of symmetry |
| Chain double-dashed | `PHANTOM` (0.18) | Section / cutting plane / alternate position |
| Continuous thin | `Continuous` (0.18–0.25) | Dimensions, hatch, leaders |
## ISO 13567 — Layer naming (4-field)
Format: `<Agent2c>-<Element6c>-<Presentation2c>-<Status1c>`
| Field | Length | Examples |
|-------|--------|----------|
| Agent | 2 chars | A=Architectural, S=Structural, M=Mechanical, P=Plumbing, E=Electrical |
| Element | 6 chars | GEOMET=geometry, HIDDEN=hidden edges, DIMEN=dimensions, TITLE=title block |
| Presentation | 2 chars | E=edge, T=text, H=hatch |
| Status | 1 char | N=new, E=existing, D=demolish |
Example layer set ships in `engineering/layers.py::ISO13567_LAYERS`.
## ISO 129-1 — Dimensioning rules
1. **Each dimension is given once** — never repeat.
2. **Extension lines** project at least 8× line width past the dim line.
3. **Decimal separator** is the comma (per ISO; we accept point too).
4. **Text orientation**: read from below the drawing or from the right side.
5. **Font**: ISO 3098 (`isocp.shx` ships with AutoCAD).
6. **Dimension styles**: chain, baseline, ordinate are the three standard
layouts — choose one per drawing and stick to it (`dim_style` in PlanSpec).
## ISO 7200 — Title-block fields
Mandatory:
- Legal owner (Anka-Makine)
- Drawing number
- Title
- Date
- Sheet / total sheets
- Approver name + signature
- Creator name
- Document type
Common optional:
- Revision
- Scale (matches PlanSpec.scale)
- Projection symbol (1st angle ISO E vs 3rd angle ISO A)
This skill assumes you call `titleblock_apply_iso_a3` (or its A3 cousin) to
populate these fields after `drawing_finalize`'s validator runs.
## ISO 14617 / 10628 — P&ID symbology
When `layer_set_id="pid"` is passed to `drawing_plan`, the bootstrap creates
the P&ID layer set:
| Layer | Linetype | Lineweight | Purpose |
|-------|----------|------------|---------|
| `PROCESS-PIPING-MAIN` | Continuous | 0.70 | Main process line (heavy) |
| `PROCESS-PIPING-SECONDARY` | Continuous | 0.50 | Secondary process line |
| `PROCESS-EQUIPMENT` | Continuous | 0.50 | Vessels / pumps / drums |
| `PROCESS-VALVES` | Continuous | 0.35 | Valves & fittings |
| `INSTRUMENT-SYMBOL` | Continuous | 0.35 | Instrument bubbles (FCFs) |
| `INSTRUMENT-LINE-SIGNAL` | DASHED | 0.18 | Pneumatic / electrical signal |
| `INSTRUMENT-TAG-TEXT` | Continuous | 0.25 | Tag labels |
| `ELECTRICAL-LINE` | DASHDOT | 0.18 | Electrical line |
| `UTILITY-LINE` | PHANTOM | 0.25 | Steam / water / air utility |
| `INSULATION-HATCH` | Continuous | 0.13 | Insulation hatching |
### ISA-5.1 tag conventions (P&ID instruments)
`<First letter><Second letter>-<Loop number><Suffix?>`
| First letter (measured variable) | Second letter (function) |
|----------------------------------|--------------------------|
| F = Flow | T = Transmitter |
| P = Pressure | I = Indicator |
| T = Temperature | C = Controller |
| L = Level | V = Valve |
| A = Analytical | S = Switch |
Examples: `FT-101` (flow transmitter, loop 101), `PCV-203A` (pressure control
valve, loop 203, A spare).
@@ -1,113 +0,0 @@
# Meta-Planning Reference
The premium meta-tool layer wraps the entity primitives in a quality-first
workflow. Schemas live in `engineering/plan_spec.py`; checks live in
`engineering/critique.py`.
## `drawing_plan(intent, sheet_size, scale, layer_set_id, view_count, dim_style, notes)`
Commits a `PlanSpec` to the backend instance. Always call FIRST.
```yaml
PlanSpec:
intent: str # "L-bracket 50x80"
sheet_size: A4 | A3 | A2 | A1 | A0 # default A3
scale: float # 1.0 = 1:1
layer_set_id: mech | pid | iso13567 # default mech
view_count: int # default 1
dim_style: chain | baseline | ordinate | mixed # default chain
notes: list[str] # free-form constraints
```
The PlanSpec is held by the backend (not persisted to DXF) and replayed by
`drawing_critique` at finalize time.
## `drawing_critique(focus=None)`
Returns a list of `Issue` instances. **Empty list = pass.**
```yaml
Issue:
severity: error | warning | info
focus: <CritiqueFocus enum>
message: str
handles: list[str] # entities involved
detail: dict # numeric context
```
`CritiqueFocus` is a **closed enum** — adding a new focus requires a code change
to `plan_spec.py` AND `critique.py`. This is intentional: it keeps the critique
scope from drifting.
| Focus | Checks |
|-------|--------|
| `iso128` | Layer lineweights are in {0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0} mm |
| `layer_color` | Engineering layers keep their canonical ACI color |
| `dim_overlap` | No two dimensions within 5 mm of each other |
| `untrimmed_corner` | No two LINE endpoints within 0.5 mm but not exactly equal |
| `duplicate_entities` | No two LINEs with identical (or reversed) endpoints |
| `construction_left` | CONSTRUCTION layer is empty |
`focus=None` runs all six. Pass a list (e.g. `["construction_left", "untrimmed_corner"]`)
to scope a check during iterative editing.
## `point_from_snap(handle, snap, ref_x, ref_y)`
Eliminates LLM coordinate guessing. Returns `(x, y)` tuple.
| `snap` | LINE | CIRCLE/ARC | Required |
|--------|------|-----------|----------|
| `end` | start or end (closer to ref) | arc start or end | — (uses ref if given) |
| `mid` | midpoint | arc midpoint | — |
| `center` | — | center | — |
| `quad` | — | nearest of E/N/W/S quadrants | — (uses ref if given) |
| `perp` | foot of perpendicular from ref | — | `ref_x`, `ref_y` |
| `near` | nearest point on segment (clamped) | nearest on circle/arc | `ref_x`, `ref_y` |
| `int` | (V2 — needs 2nd handle) | | |
## `construction_xline(x, y, angle_deg, layer="CONSTRUCTION")`
Creates an infinite construction line on a non-printing layer (color 250,
lightest weight). Use as scaffolding when laying out geometry.
`construction_clear(layer="CONSTRUCTION")` — bulk-deletes everything on the
layer. Returns `{"ok": True, "deleted": N}`. **Idempotent.**
## `drawing_apply_iso_layers(standard)`
| `standard` | Layer count | Use case |
|------------|-------------|----------|
| `mech` | 10 | Mechanical part / assembly drawings |
| `pid` | 15 | Process & Instrumentation Diagrams (ISO 10628) |
| `iso13567` | 10 | ISO 13567 layer-naming convention starter |
Idempotent — existing layers are not modified.
## `dimension_auto(handles, style, offset)`
V1 supports LINE entities only. Generates one or more dimensions using the
chosen style.
| `style` | Behaviour |
|---------|-----------|
| `chain` | One linear dim per handle, perpendicular-offset from each segment |
| `baseline` | All dims share the first segment's start point as baseline |
| `ordinate` | X and Y coordinate dims for each segment endpoint |
`offset` (mm) controls the dim-line distance from the geometry.
## `entity_select_smart(predicate)`
Predicate is a dict; **all keys optional, AND-ed**:
```yaml
predicate:
type: LINE | CIRCLE | ARC | ... # entity type filter
layer: <name> # exact match
near: [x, y, radius] # entity within radius of point
length_range: [min, max] # LINE/ARC only
color: <ACI int> # exact match
```
Returns a list of `EntityInfo`. Use this in lieu of memorising handles when
chaining `dimension_auto` or other operations.
@@ -1,159 +0,0 @@
# Premium Workflow — Full Examples
The 9-step workflow walked through end-to-end. Use these as templates.
## Template 1 — Mechanical part (L-bracket with rounded inner corner)
```python
# 1. PLAN
plan = await drawing_plan(
intent="L-bracket 50x80, R5 inner corner, 5mm thick",
sheet_size="A4",
scale=1.0,
layer_set_id="mech",
dim_style="chain",
)
# 2. LAYERS
await drawing_apply_iso_layers("mech")
# 3. CONSTRUCTION (optional — set up reference grid)
await construction_xline(0, 0, 0) # horizontal axis
await construction_xline(0, 0, 90) # vertical axis
# 4. GEOMETRY (on GEOMETRY layer)
l1 = await entity_create_line(0, 0, 100, 0, layer="GEOMETRY")
l2 = await entity_create_line(100, 0, 100, 80, layer="GEOMETRY")
# 5. CORNERS
arc = await entity_fillet(l1["handle"], l2["handle"], radius=5)
# 6. SELECT for dimensioning (avoid handle memorization)
geometry_lines = await entity_select_smart({
"type": "LINE",
"layer": "GEOMETRY",
})
# 7. DIMENSIONS
dims = await dimension_auto(
[e["handle"] for e in geometry_lines],
style="chain",
offset=12.0,
)
# 8. CRITIQUE
issues = await drawing_critique(focus=None)
assert issues == [], f"Premium gate failed: {issues}"
# 9. CLEAR + FINALIZE
await construction_clear()
await drawing_finalize(
save_path="C:/work/l-bracket.dwg",
screenshot_path="C:/work/l-bracket.png",
)
```
## Template 2 — P&ID flow control loop
```python
# 1. PLAN
plan = await drawing_plan(
intent="Flow control loop 101: pump → valve → vessel",
sheet_size="A3",
scale=1.0,
layer_set_id="pid",
dim_style="ordinate",
notes=["ISA-5.1 tag conventions", "All process lines on PROCESS-PIPING-MAIN"],
)
# 2. LAYERS — bootstraps PROCESS-PIPING-MAIN, INSTRUMENT-*, etc.
await drawing_apply_iso_layers("pid")
# 3. EQUIPMENT (vessels, pumps — use blocks ideally)
await entity_create_circle(50, 50, 8, layer="PROCESS-EQUIPMENT") # pump body
await entity_create_rectangle(150, 30, 200, 70, layer="PROCESS-EQUIPMENT") # vessel
# 4. PROCESS LINES
main = await entity_create_line(58, 50, 150, 50, layer="PROCESS-PIPING-MAIN")
# 5. INSTRUMENT BUBBLES (circle + tag text)
ft = await entity_create_circle(100, 70, 5, layer="INSTRUMENT-SYMBOL")
await entity_create_text("FT-101", 95, 68, height=2.5, layer="INSTRUMENT-TAG-TEXT")
# 6. SIGNAL LINE (instrument bubble → controller)
await entity_create_line(100, 75, 100, 90, layer="INSTRUMENT-LINE-SIGNAL")
# 7. (no dimensions on P&ID typically — skip)
# 8. CRITIQUE — focus on what matters for P&ID
issues = await drawing_critique(focus=["layer_color", "iso128", "construction_left"])
assert issues == []
# 9. FINALIZE
await drawing_finalize(save_path="C:/work/loop-101.dwg")
```
## Template 3 — Iterative drawing (corrections welcome)
```python
# Initial plan
plan = await drawing_plan("Bracket draft", sheet_size="A4")
await drawing_apply_iso_layers("mech")
# Draw something wrong: leaves a gap
l1 = await entity_create_line(0, 0, 49.8, 0, layer="GEOMETRY")
l2 = await entity_create_line(50, 0, 50, 30, layer="GEOMETRY")
# Critique — flags untrimmed_corner
issues = await drawing_critique(focus=["untrimmed_corner"])
# issues[0].handles = [l1.handle, l2.handle]
# Fix in place: extend l1 to meet l2
await entity_extend(l1["handle"], l2["handle"])
# Re-critique
issues = await drawing_critique(focus=["untrimmed_corner"])
assert issues == []
```
## Common patterns
### Snap-driven coordinates
```python
# BAD: guessing
await entity_create_line(50, 50, 100, 50)
# GOOD: snap from existing geometry
mid = await point_from_snap(some_line["handle"], "mid")
end = await point_from_snap(other_line["handle"], "end", ref_x=100, ref_y=50)
await entity_create_line(mid["x"], mid["y"], end["x"], end["y"])
```
### Construction-first workflow
```python
# Lay out reference geometry
await construction_xline(0, 0, 0) # x-axis
await construction_xline(0, 0, 90) # y-axis
await construction_xline(100, 0, 90) # vertical at x=100
# Snap real geometry to construction intersections
ref = await point_from_snap(some_line["handle"], "perp", ref_x=0, ref_y=50)
# ... create real geometry on GEOMETRY layer ...
# Wipe scaffold before finalize
await construction_clear()
```
### Block-first repetition
```python
# When 12 holes are needed: create one block, then array
hole = await entity_create_circle(0, 0, 5, layer="GEOMETRY")
await block_create_from_entities("HOLE_M10", [hole["handle"]], 0, 0)
# Insert + array — far cheaper than 12 entity_create_circle calls
ref = await block_insert("HOLE_M10", x=0, y=0)
await entity_array_polar(ref["handle"], count=12, fill_angle=360, center_x=50, center_y=50)
```
-47
View File
@@ -1,47 +0,0 @@
---
alwaysApply: true
---
ASK MODE – Mandatory and Absolute Rules
1. AskQuestion Requirement
- Every generated response must end with AskQuestion. The conversation can never be terminated.
- AskQuestion is the final action.
- It contains exactly one question and exactly 2 options.
- The options must be clear and closed-ended.
- The “Other” option is visible in the user interface (the user may enter a custom prompt).
- The fallback option must never terminate the conversation.
---
1. FRT Lock (Infinite Continuity)
- Unless the user writes “FRT” alone, Ask Mode must never end.
- Expressions such as “finished”, “stop”, “end”, thanks, or making a selection are invalid.
- The only exit key for Ask Mode is: FRT
- If FRT appears within another sentence, it is invalid. It must be written alone and in uppercase.
---
1. Medium / Large Task Rule
- Sub-agent usage is mandatory.
- Model: Claude 4.6 Max Thinking Fast max (the only authorized model)
- A single sub-agent cannot be used.
- 4 parallel sub-agent capacity must be utilized.
---
1. Sleep Protocol
- sleep 'value'
- Runs in the foreground, no timeout (minimum 70 seconds if required).
- Only run_terminal_cmd or Shell.
- is_background is prohibited.
---
1. Operational Principle
- Priority: Speed + Accuracy
- Bulk changes: search_replace
- Scattered changes: read_file → edit → write
- Line-based single changes are not allowed.
- Parallel execution is essential.
1. Please dont switch ‘’Plan Mode‘‘
-156
View File
@@ -1,156 +0,0 @@
---
name: autocad-mcp
description: |
Automate AutoCAD drawing operations through AutoCAD MCP Pro server (FastMCP 3.0).
86 tools for drawings, entities, layers, blocks, dimensions, analysis, batch ops,
templates, and validation. Dual backends: COM (live AutoCAD) and ezdxf (headless DXF).
Trigger when: (1) User mentions AutoCAD, DXF, DWG, or CAD drawings,
(2) User wants to create/modify engineering drawings programmatically,
(3) Working with tools prefixed drawing_, entity_, layer_, block_, dimension_,
analysis_, view_, system_, template_, or validation_,
(4) User asks about CAD automation, drafting workflows, or MCP server setup.
---
# AutoCAD MCP Pro
## Contents
- [Quick Start](#quick-start)
- [Backend Selection](#backend-selection)
- [Key Concepts](#key-concepts)
- [Tool Groups](#tool-groups)
- [Common Workflows](#common-workflows)
- [Security Rules](#security-rules)
- [Architecture](#architecture)
- [Troubleshooting](#troubleshooting)
- [Examples](#examples)
## Quick Start
Standard workflow for any drawing task:
```
1. drawing_new() # or drawing_open(path)
2. layer_create(name, color, linetype) # set up layers
3. entity_create_*(...) # draw geometry
4. dimension_*(...) # add dimensions
5. analysis_entity_stats() # verify drawing
6. drawing_save() # persist changes
```
Use `transaction_begin()` before complex operations. If something goes wrong, `transaction_rollback()` reverts all changes since begin.
For batch operations, use `entity_batch_create(entities)` with a list of entity definitions — handles are returned for each created entity.
## Backend Selection
| Environment Variable | Behavior |
|---------------------|----------|
| `AUTOCAD_MCP_BACKEND=auto` | Try COM first, fall back to ezdxf (default) |
| `AUTOCAD_MCP_BACKEND=com` | Force COM — requires running AutoCAD on Windows |
| `AUTOCAD_MCP_BACKEND=ezdxf` | Force ezdxf — headless DXF ops, no AutoCAD needed |
COM-only features: `system_run_command`, `system_run_lisp`, `view_zoom_extents/window` (view ops are no-ops in ezdxf). Screenshots: COM uses Win32 capture, ezdxf uses matplotlib render.
## Key Concepts
**Entity handles**: Hex strings (e.g. `"1A2B"`) returned by all create/copy operations. Required for modify/query operations (`entity_get`, `entity_move`, `entity_delete`, etc.).
**Coordinates**: Drawing units (mm default). Angles in degrees, counter-clockwise from X axis.
**ACI colors**: 1–255 = specific colors, 256 = ByLayer, 0 = ByBlock. Common: 1=red, 2=yellow, 3=green, 4=cyan, 5=blue, 6=magenta, 7=white.
**Transactions**: `transaction_begin` → operations → `transaction_commit` or `transaction_rollback`. ezdxf uses full DXF snapshots; COM uses native undo marks.
## Tool Groups
| Group | Count | Key Tools | Details |
|-------|-------|-----------|---------|
| Drawing Management | 11 | `drawing_new`, `drawing_open`, `drawing_save`, `drawing_export_*` | [tools-drawing.md](references/tools-drawing.md) |
| Entity Creation | 13 | `entity_create_line/circle/arc/polyline/text/hatch/...` | [tools-entity-creation.md](references/tools-entity-creation.md) |
| Entity Modification | 13 | `entity_move/copy/rotate/scale/mirror/offset/delete/...` | [tools-entity-modification.md](references/tools-entity-modification.md) |
| Dimensions | 5 | `dimension_linear/aligned/angular/radius/diameter` | [tools-dimensions.md](references/tools-dimensions.md) |
| Layer Management | 12 | `layer_create/delete/freeze/thaw/lock/unlock/isolate/...` | [tools-layers.md](references/tools-layers.md) |
| Block Operations | 7 | `block_insert/explode/create_from_entities/attributes/...` | [tools-blocks.md](references/tools-blocks.md) |
| Analysis | 9 | `analysis_entity_stats/find_in_region/measure_*/bounding_box` | [tools-analysis.md](references/tools-analysis.md) |
| Batch & Templates | 4 | `entity_batch_create/modify`, `template_apply_layers/list` | [tools-batch-template.md](references/tools-batch-template.md) |
| Validation | 1 | `validation_check` | [tools-batch-template.md](references/tools-batch-template.md) |
| View & Screenshot | 5 | `view_zoom_extents/window/screenshot/zoom_and_screenshot` | [tools-view-system.md](references/tools-view-system.md) |
| Transactions | 3 | `transaction_begin/commit/rollback` | [tools-view-system.md](references/tools-view-system.md) |
| System | 6 | `system_status/get_variable/run_command/run_lisp/about` | [tools-view-system.md](references/tools-view-system.md) |
**Resources** (6): `autocad://drawing`, `autocad://layers`, `autocad://blocks`, `autocad://stats`, `autocad://status`, `autocad://entities/{layer_name}`
**Prompt templates** (5): `prompt_floor_plan`, `prompt_pid_diagram`, `prompt_electrical_schematic`, `prompt_mechanical_drawing`, `prompt_quick_drawing`
## Common Workflows
**New drawing from scratch**: Set up layers first, then draw entities, add dimensions, save.
See [workflows.md](references/workflows.md) for step-by-step checklists.
**Edit existing DXF**: `drawing_open` → `entity_list` to find entities → modify/delete → save.
**Batch operations**: `entity_batch_create([{type:"line", ...}, {type:"circle", ...}])` — single call, multiple entities.
**Template-based**: `template_apply_layers("architectural")` creates standard layer sets, then add content.
**Quality check**: `validation_check(["empty_layers", "zero_length", "duplicate_entities"])` reports issues.
## Security Rules
All file paths pass through `validate_path()`:
- Path traversal blocked (`../` patterns rejected)
- `ALLOWED_PATHS` env restricts accessible directories
- Write operations require `allow_write=True`
Commands pass through `sanitize_command()`:
- Dangerous patterns blocked: ERASE ALL, DELETE, PURGE, QUIT
- LISP sanitized: `startapp`, `vl-file-delete`, `dos_*` rejected
- Override: `DANGEROUS_COMMANDS_ENABLED=true`
Details: [security.md](references/security.md)
## Architecture
```
server.py FastMCP 3.0 server (86 tools, 6 resources, 5 prompts)
├── config.py Centralized pydantic-settings configuration
├── security.py Path validation + command sanitization
└── backends/
├── base.py AutoCADBackend ABC + data models
├── ezdxf_backend.py Headless DXF via ezdxf (async via _async wrapper)
└── com_backend.py Live AutoCAD via pywin32 COM (STA single-thread)
```
Middleware stack: ErrorHandling → AuditLog → Timing → Logging
Adding a new tool: (1) abstract method in `base.py` → (2) implement in `ezdxf_backend.py` → (3) implement in `com_backend.py` → (4) register in `server.py` with `@mcp.tool` → (5) test
Details: [architecture.md](references/architecture.md)
## Troubleshooting
| Error | Solution |
|-------|----------|
| "Backend unavailable" | Call `drawing_new()` or `drawing_open(path)` first |
| "Entity handle not found" | Use `entity_list()` to get valid handles |
| "Layer not found" | `layer_list()` to see existing, `layer_create()` to add |
| "Path validation failed" | Use absolute paths within `ALLOWED_PATHS` |
| "Command rejected" | Check `DANGEROUS_COMMANDS_ENABLED` setting |
| "COM backend failed" | Verify AutoCAD is running, or switch to ezdxf |
| "View ops are no-ops" | View/zoom only work with COM backend |
Full list: [troubleshooting.md](references/troubleshooting.md)
## Examples
- [Basic drawing workflow](examples/basic-workflow.md) — Room with walls, door, dimensions
- [Floor plan](examples/floor-plan.md) — Multi-room apartment layout
- [Mechanical part](examples/mechanical-part.md) — Flange with bolt pattern
## Utility Scripts
**Setup check**: `python scripts/check_setup.py` — Verify dependencies and configuration
**Drawing validation**: `python scripts/validate_drawing.py` — Check for common drawing issues
@@ -1,50 +0,0 @@
{
"architectural": {
"description": "Standard architectural drawing layers",
"layers": [
{"name": "WALLS", "color": 7, "linetype": "Continuous", "description": "Wall outlines"},
{"name": "DOORS", "color": 3, "linetype": "Continuous", "description": "Door swings and frames"},
{"name": "WINDOWS", "color": 5, "linetype": "Continuous", "description": "Window openings"},
{"name": "DIMENSIONS", "color": 1, "linetype": "Continuous", "description": "Dimension annotations"},
{"name": "TEXT", "color": 2, "linetype": "Continuous", "description": "Text labels and notes"},
{"name": "FURNITURE", "color": 8, "linetype": "Continuous", "description": "Furniture outlines"},
{"name": "PLUMBING", "color": 4, "linetype": "Continuous", "description": "Plumbing fixtures"},
{"name": "HVAC", "color": 6, "linetype": "Continuous", "description": "HVAC equipment and ducts"}
]
},
"mechanical": {
"description": "Standard mechanical drawing layers",
"layers": [
{"name": "OUTLINE", "color": 7, "linetype": "Continuous", "description": "Part outlines"},
{"name": "CENTERLINE", "color": 1, "linetype": "CENTER", "description": "Center lines and axes"},
{"name": "HIDDEN", "color": 8, "linetype": "HIDDEN", "description": "Hidden edges"},
{"name": "DIMENSIONS", "color": 5, "linetype": "Continuous", "description": "Dimensions"},
{"name": "NOTES", "color": 2, "linetype": "Continuous", "description": "Notes and callouts"},
{"name": "SECTION", "color": 3, "linetype": "Continuous", "description": "Section lines and hatching"},
{"name": "DETAIL", "color": 4, "linetype": "Continuous", "description": "Detail views"}
]
},
"electrical": {
"description": "Standard electrical drawing layers",
"layers": [
{"name": "POWER", "color": 1, "linetype": "Continuous", "description": "Power circuits"},
{"name": "LIGHTING", "color": 2, "linetype": "Continuous", "description": "Lighting circuits"},
{"name": "DATA", "color": 3, "linetype": "Continuous", "description": "Data and communication"},
{"name": "FIRE_ALARM", "color": 4, "linetype": "Continuous", "description": "Fire alarm system"},
{"name": "HVAC_CTRL", "color": 5, "linetype": "Continuous", "description": "HVAC controls"},
{"name": "PANELS", "color": 7, "linetype": "Continuous", "description": "Panel boards and equipment"},
{"name": "CONDUIT", "color": 8, "linetype": "Continuous", "description": "Conduit runs"}
]
},
"piping": {
"description": "Standard piping/P&ID drawing layers",
"layers": [
{"name": "PROCESS", "color": 7, "linetype": "Continuous", "description": "Process piping"},
{"name": "UTILITY", "color": 3, "linetype": "Continuous", "description": "Utility piping"},
{"name": "INSTRUMENTS", "color": 1, "linetype": "Continuous", "description": "Instrumentation"},
{"name": "EQUIPMENT", "color": 5, "linetype": "Continuous", "description": "Equipment outlines"},
{"name": "VALVES", "color": 2, "linetype": "Continuous", "description": "Valves and fittings"},
{"name": "LABELS", "color": 4, "linetype": "Continuous", "description": "Labels and tags"}
]
}
}
@@ -1,72 +0,0 @@
# Example: Basic Room Drawing
Create a simple room with walls, a door opening, dimensions, and labels.
## Setup
```
drawing_new()
layer_create("WALLS", color=7)
layer_create("DOORS", color=3)
layer_create("DIMENSIONS", color=1)
layer_create("TEXT", color=2)
```
## Draw Walls (5m x 4m room)
```
layer_set_current("WALLS")
# Outer walls as rectangle
entity_create_rectangle(x1=0, y1=0, x2=5000, y2=4000)
# Returns: {handle: "A1", type: "LWPOLYLINE", ...}
```
## Add Door Opening (0.9m wide on south wall)
```
layer_set_current("DOORS")
# Door opening represented as an arc (90° swing)
entity_create_arc(cx=2000, cy=0, radius=900, start_angle=0, end_angle=90)
# Returns: {handle: "B2", type: "ARC", ...}
# Door frame lines
entity_create_line(x1=2000, y1=0, x2=2000, y2=100)
entity_create_line(x1=2900, y1=0, x2=2900, y2=100)
```
## Add Dimensions
```
layer_set_current("DIMENSIONS")
# Room width
dimension_linear(x1=0, y1=0, x2=5000, y2=0, dim_x=2500, dim_y=-500)
# Room height
dimension_linear(x1=5000, y1=0, x2=5000, y2=4000, dim_x=5500, dim_y=2000)
# Door width
dimension_linear(x1=2000, y1=0, x2=2900, y2=0, dim_x=2450, dim_y=-300)
```
## Add Labels
```
layer_set_current("TEXT")
entity_create_text(text="BEDROOM", x=2500, y=2000, height=200)
entity_create_text(text="5000", x=2500, y=-800, height=150)
```
## Verify and Save
```
analysis_entity_stats()
# Expected: ~8 entities across 4 layers
validation_check(["empty_layers"])
# Should report no issues
drawing_save("C:/drawings/room.dxf")
```
@@ -1,100 +0,0 @@
# Example: Apartment Floor Plan
Create a 3-room apartment: living room, bedroom, kitchen with doors and windows.
## Setup
```
drawing_new()
template_apply_layers("architectural")
# Creates: WALLS, DOORS, WINDOWS, DIMENSIONS, TEXT, FURNITURE, PLUMBING, HVAC
```
## Draw Exterior Walls (12m x 8m)
```
layer_set_current("WALLS")
# Exterior boundary
entity_create_rectangle(x1=0, y1=0, x2=12000, y2=8000)
```
## Draw Interior Walls
```
# Living/bedroom divider (vertical at x=5000)
entity_create_line(x1=5000, y1=0, x2=5000, y2=8000)
# Kitchen divider (horizontal at y=4000, from x=5000)
entity_create_line(x1=5000, y1=4000, x2=12000, y2=4000)
```
## Add Doors (0.9m openings)
```
layer_set_current("DOORS")
# Living room door (south wall)
entity_create_arc(cx=2000, cy=0, radius=900, start_angle=0, end_angle=90)
# Bedroom door (on divider wall)
entity_create_arc(cx=5000, cy=2000, radius=900, start_angle=90, end_angle=180)
# Kitchen door (on divider wall)
entity_create_arc(cx=7000, cy=4000, radius=900, start_angle=0, end_angle=90)
```
## Add Windows (1.5m wide)
```
layer_set_current("WINDOWS")
# Living room window (north wall)
entity_create_line(x1=1500, y1=8000, x2=3000, y2=8000, color=5)
# Bedroom window (east wall)
entity_create_line(x1=12000, y1=5500, x2=12000, y2=7000, color=5)
# Kitchen window (east wall)
entity_create_line(x1=12000, y1=1500, x2=12000, y2=3000, color=5)
```
## Add Dimensions
```
layer_set_current("DIMENSIONS")
# Overall width
dimension_linear(x1=0, y1=0, x2=12000, y2=0, dim_x=6000, dim_y=-800)
# Overall height
dimension_linear(x1=0, y1=0, x2=0, y2=8000, dim_x=-800, dim_y=4000)
# Room widths
dimension_linear(x1=0, y1=8000, x2=5000, y2=8000, dim_x=2500, dim_y=8500)
dimension_linear(x1=5000, y1=8000, x2=12000, y2=8000, dim_x=8500, dim_y=8500)
```
## Add Room Labels
```
layer_set_current("TEXT")
entity_create_text(text="LIVING ROOM", x=2500, y=4000, height=300)
entity_create_mtext(text="12.0 m²", x=2500, y=3500, width=2000, height=200)
entity_create_text(text="BEDROOM", x=8500, y=6000, height=300)
entity_create_mtext(text="28.0 m²", x=8500, y=5500, width=2000, height=200)
entity_create_text(text="KITCHEN", x=8500, y=2000, height=300)
entity_create_mtext(text="28.0 m²", x=8500, y=1500, width=2000, height=200)
```
## Verify
```
analysis_entity_stats()
analysis_layer_stats()
validation_check(["empty_layers", "zero_length"])
drawing_save("C:/drawings/apartment.dxf")
```
@@ -1,105 +0,0 @@
# Example: Flange with Bolt Pattern
Create a circular flange plate with bolt holes using polar array.
## Setup
```
drawing_new()
template_apply_layers("mechanical")
# Creates: OUTLINE, CENTERLINE, HIDDEN, DIMENSIONS, NOTES, SECTION, DETAIL
```
## Draw Flange Outline
```
layer_set_current("OUTLINE")
# Outer flange circle (diameter 200mm)
entity_create_circle(cx=0, cy=0, radius=100)
# Returns: {handle: "F1", ...}
# Inner bore (diameter 50mm)
entity_create_circle(cx=0, cy=0, radius=25)
# Returns: {handle: "F2", ...}
```
## Add Centerlines
```
layer_set_current("CENTERLINE")
# Horizontal centerline
entity_create_line(x1=-120, y1=0, x2=120, y2=0, linetype="CENTER")
# Vertical centerline
entity_create_line(x1=0, y1=-120, x2=0, y2=120, linetype="CENTER")
```
## Create Bolt Hole Pattern
```
layer_set_current("OUTLINE")
# First bolt hole on bolt circle (PCD = 150mm diameter, so radius = 75mm)
entity_create_circle(cx=75, cy=0, radius=6)
# Returns: {handle: "B1", ...}
# Create polar array: 6 holes evenly spaced
entity_array_polar(handle="B1", center_x=0, center_y=0, count=6, angle=360)
# Returns: list of 5 new handles (original + 5 copies = 6 total)
```
## Add Dimensions
```
layer_set_current("DIMENSIONS")
# Outer diameter
dimension_diameter(x1=-100, y1=0, x2=100, y2=0)
# Inner bore diameter
dimension_diameter(x1=-25, y1=0, x2=25, y2=0)
# Bolt circle diameter
dimension_radius(center_x=0, center_y=0, radius=75, angle=45)
# Bolt hole diameter
dimension_diameter(x1=69, y1=0, x2=81, y2=0)
```
## Add Notes
```
layer_set_current("NOTES")
entity_create_mtext(
text="FLANGE PLATE\P6x M12 HOLES ON PCD 150\PMATERIAL: SS304",
x=-120, y=-140,
width=240, height=5
)
```
## Create Block from Bolt Hole
```
# Select all bolt hole entities and create a reusable block
# (Get handles from the array result)
block_create_from_entities(
name="BOLT_PATTERN_6x150",
handles=["B1", ...], # all bolt hole handles
base_x=0, base_y=0
)
```
## Verify
```
analysis_entity_stats()
# Expected: circles, lines, dimensions, mtext
analysis_bounding_box()
# Expected: approximately -120 to 120 in both axes
drawing_save("C:/drawings/flange.dxf")
```
@@ -1,60 +0,0 @@
# Architecture
## File Structure
```
server.py FastMCP 3.0 server — all tool/resource/prompt registrations
├── config.py Settings class (pydantic-settings, env vars)
├── security.py validate_path() + sanitize_command() + sanitize_lisp()
└── backends/
├── base.py AutoCADBackend ABC + data models
├── ezdxf_backend.py Headless DXF ops via ezdxf library
└── com_backend.py Live AutoCAD control via pywin32 COM
```
## Dual-Backend Strategy Pattern
`AutoCADBackend` (in `base.py`) is an abstract base class. Both backends implement the full interface. The server creates exactly one backend at startup via `_make_backend()`:
1. Check `AUTOCAD_MCP_BACKEND` env var
2. If `auto` or `com` on Windows: try COM → fall back to ezdxf
3. If non-Windows or `ezdxf`: use ezdxf
## Data Models
Defined in `base.py`:
- **EntityInfo**: `{handle, type, layer, color, linetype, visible, properties}`
- **LayerInfo**: `{name, color, linetype, lineweight, is_on, is_frozen, is_locked, is_current}`
- **BlockInfo**: `{name, origin, attribute_count, entity_count, is_xref, description}`
- **DrawingInfo**: `{name, full_path, saved, entity_count, layer_count, block_count, extents_min, extents_max, units, version, backend}`
- **CommandResult**: `{ok, payload, error}`
## Backend Differences
| Feature | ezdxf | COM |
|---------|-------|-----|
| Platform | Any | Windows only |
| AutoCAD needed | No | Yes (running) |
| View/zoom | No-op | Full control |
| Screenshots | matplotlib render | Win32 window capture |
| Commands/LISP | Not supported | Full support |
| Undo | DXF snapshot stack | Native AutoCAD undo |
| Threading | `asyncio.to_thread` via `_async()` | `ThreadPoolExecutor` (single thread, STA) |
| File formats | DXF only | DWG + DXF |
## FastMCP Server
The `mcp` object is configured with:
- **Lifespan**: `autocad_lifespan` — initializes backend, stores in `ctx.lifespan_context["backend"]`
- **Middleware**: ErrorHandling → AuditLog (custom timing) → Timing → Logging
- **`_backend(ctx)`**: Helper that retrieves backend from context, raises `ToolError` if unavailable
## Adding a New Tool
1. Add abstract method to `AutoCADBackend` in `backends/base.py`
2. Implement in `EzdxfBackend` — wrap sync calls with `_async(func)`
3. Implement in `ComBackend` — wrap COM calls with `self._com(func)`
4. Register in `server.py`: `@mcp.tool(...)` calling `_backend(ctx).method(...)`
5. Use `_dc(result)` to convert dataclass returns to dicts
6. Write tests in `tests/`
@@ -1,53 +0,0 @@
# Security
## Path Validation
All file-path tools pass through `validate_path()` in `security.py`.
**How it works**:
- Resolves path via `Path.resolve()` (removes `..`, symlinks)
- Checks against `ALLOWED_PATHS` env var (comma-separated directories)
- If `ALLOWED_PATHS` is empty → all paths allowed (backward compat)
- Separate `allow_write` flag for read vs write operations
**Tools with path validation**:
- `drawing_open` (read)
- `drawing_save`, `drawing_save_as` (write)
- `drawing_export_dxf`, `drawing_export_pdf` (write)
- `drawing_new` with template (read)
**Blocked examples**:
- `../../../etc/passwd` → rejected (traversal)
- `C:\Windows\System32\...` → rejected if not in ALLOWED_PATHS
- Relative paths with `..` → always rejected
## Command Sanitization
`sanitize_command()` checks `system_run_command` input:
**Blocked patterns**: `ERASE`, `DELETE`, `PURGE`, `QUIT`, `CLOSE`, `_QUIT`, shell-related commands
**Override**: Set `DANGEROUS_COMMANDS_ENABLED=true` to bypass.
## LISP Sanitization
`sanitize_lisp()` checks `system_run_lisp` input:
**Blocked functions**: `startapp`, `vl-file-delete`, `vl-file-rename`, `dos_*` (file system access), `command` with dangerous args
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `ALLOWED_PATHS` | (empty) | Comma-separated allowed directories. Empty = all allowed |
| `DANGEROUS_COMMANDS_ENABLED` | `false` | Allow blocked commands |
| `AUTOCAD_MCP_BACKEND` | `auto` | Backend selection |
| `MAX_UNDO_STACK` | `50` | Max undo snapshots (ezdxf) |
| `LOG_LEVEL` | `INFO` | Logging level |
## Best Practices
- Always set `ALLOWED_PATHS` in production to restrict file access
- Never pass raw user input to `system_run_command` or `system_run_lisp`
- Use `transaction_begin/rollback` for destructive batch operations
- Review audit logs for suspicious tool calls (AuditMiddleware logs all calls)
@@ -1,73 +0,0 @@
# Analysis Tools (9)
Tools for querying and measuring drawing contents.
---
## analysis_entity_stats
No parameters. Returns entity counts grouped by type and by layer.
```json
{
"total": 150,
"by_type": {"LINE": 50, "CIRCLE": 20, "TEXT": 30, ...},
"by_layer": {"WALLS": 40, "DOORS": 10, ...}
}
```
## analysis_find_in_region
Find entities within a rectangular region.
| Param | Type | Description |
|-------|------|-------------|
| `x1, y1` | `float` | Region minimum corner |
| `x2, y2` | `float` | Region maximum corner |
| `type_filter` | `str \| null` | Filter by entity type |
Returns list of entity infos within the bounding box.
## analysis_measure_distance
Measure distance between two points.
| Param | Type | Description |
|-------|------|-------------|
| `x1, y1` | `float` | Point 1 |
| `x2, y2` | `float` | Point 2 |
Returns `{distance, dx, dy, angle}`.
## analysis_measure_area
Calculate area of a polygon.
| Param | Type | Description |
|-------|------|-------------|
| `points` | `list[list[float]]` | Polygon vertices (min 3 points) |
Returns `{area, perimeter, centroid}`.
## analysis_bounding_box
No parameters. Returns `{min_x, min_y, max_x, max_y, width, height}` for all entities.
## analysis_select_by_layer
Get all entities on a specific layer.
| Param | Type | Description |
|-------|------|-------------|
| `layer_name` | `str` | Layer to select from |
Returns list of entity infos.
## analysis_select_by_type
Get all entities of a specific type.
| Param | Type | Description |
|-------|------|-------------|
| `entity_type` | `str` | Type: LINE, CIRCLE, ARC, LWPOLYLINE, TEXT, MTEXT, INSERT, HATCH, SPLINE, ELLIPSE |
Returns list of entity infos.
## analysis_layer_stats
No parameters. Returns per-layer statistics: entity count, types present, color, state.
## analysis_entities_in_region
Alias/variant of `analysis_find_in_region` — check both for availability.
@@ -1,83 +0,0 @@
# Batch, Template & Validation Tools (5)
## Batch Operations
### entity_batch_create
Create multiple entities in a single call. Reports progress.
| Param | Type | Description |
|-------|------|-------------|
| `entities` | `list[dict]` | Entity definitions (see format below) |
Each dict requires `type` and type-specific params:
```json
[
{"type": "line", "x1": 0, "y1": 0, "x2": 100, "y2": 0},
{"type": "circle", "cx": 50, "cy": 50, "radius": 25},
{"type": "text", "text": "Hello", "x": 10, "y": 10, "height": 5},
{"type": "rectangle", "x1": 0, "y1": 0, "x2": 200, "y2": 100},
{"type": "point", "x": 75, "y": 75},
{"type": "arc", "cx": 50, "cy": 0, "radius": 10, "start_angle": 0, "end_angle": 180},
{"type": "polyline", "points": [[0,0],[10,10],[20,0]], "closed": true}
]
```
Optional per-entity: `layer`, `color`, `linetype`.
Returns `{created: [...handles], errors: [...]}`. Partial success is possible.
### entity_batch_modify
Apply multiple modifications in a single call. Reports progress.
| Param | Type | Description |
|-------|------|-------------|
| `operations` | `list[dict]` | Operation definitions |
Each dict requires `handle` and `action`:
```json
[
{"handle": "1A", "action": "move", "dx": 10, "dy": 20},
{"handle": "2B", "action": "rotate", "base_x": 0, "base_y": 0, "angle_deg": 45},
{"handle": "3C", "action": "scale", "base_x": 0, "base_y": 0, "factor": 2.0},
{"handle": "4D", "action": "delete"},
{"handle": "5E", "action": "set_properties", "layer": "WALLS", "color": 1}
]
```
Returns results per operation.
---
## Templates
### template_apply_layers
Apply a standard layer set to the current drawing.
| Param | Type | Description |
|-------|------|-------------|
| `template` | `str` | Template name: `architectural`, `mechanical`, `electrical`, `piping` |
Creates predefined layers with standard names, colors, and linetypes.
### template_list
No parameters. Returns all available templates and their layer definitions.
---
## Validation
### validation_check
Run quality checks on the drawing.
| Param | Type | Description |
|-------|------|-------------|
| `checks` | `list[str] \| null` | Check types (default: all) |
Available checks:
- `empty_layers` — Layers with no entities
- `zero_length` — Lines/arcs with zero length
- `duplicate_entities` — Overlapping identical entities
Returns `{issues: [{check, severity, message, details}, ...], summary}`.
@@ -1,68 +0,0 @@
# Block Operations Tools (7)
Blocks are reusable named groups of entities. A **block definition** is the template; a **block reference** (INSERT entity) is a placed instance with position, scale, and rotation.
---
## block_list
No parameters. Returns all block definitions: `{name, origin, attribute_count, entity_count, is_xref, description}`.
## block_insert
Place a block reference in the drawing.
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Block definition name (must exist) |
| `x, y` | `float` | Insertion point |
| `x_scale` | `float` | X scale factor (default: 1.0) |
| `y_scale` | `float` | Y scale factor (default: 1.0) |
| `rotation` | `float` | Rotation in degrees (default: 0) |
| `layer` | `str \| null` | Target layer |
Returns entity info with handle (type: INSERT).
## block_explode
Decompose a block reference into individual entities.
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Block reference (INSERT entity) handle |
Returns list of new entity handles created from explosion.
## block_get_attributes
Read attribute values from a block reference.
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Block reference handle |
Returns `{tag: value}` dict of all attributes.
## block_set_attributes
Write attribute values to a block reference.
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Block reference handle |
| `attributes` | `dict` | `{tag: value}` pairs to set |
## block_create_from_entities
Create a new block definition from existing entities.
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | New block definition name |
| `handles` | `list[str]` | Entity handles to include |
| `base_x, base_y` | `float` | Block base/insertion point |
Entities are moved into the block definition.
## block_find_references
Find all INSERT entities that reference a specific block.
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Block definition name |
Returns list of block reference entity infos.
@@ -1,55 +0,0 @@
# Dimension Tools (5)
All dimension tools return entity info with handle. Dimensions are placed on the current layer unless `layer` is specified.
---
## dimension_linear
Linear dimension between two points (horizontal or vertical measurement).
| Param | Type | Description |
|-------|------|-------------|
| `x1, y1` | `float` | First extension line origin |
| `x2, y2` | `float` | Second extension line origin |
| `dim_x, dim_y` | `float` | Dimension line position (controls placement) |
| `layer` | `str \| null` | Target layer |
## dimension_aligned
Aligned dimension — measures true distance between points regardless of angle.
| Param | Type | Description |
|-------|------|-------------|
| `x1, y1` | `float` | First point |
| `x2, y2` | `float` | Second point |
| `distance` | `float` | Offset distance for dimension line |
| `layer` | `str \| null` | Target layer |
## dimension_angular
Angular dimension between two lines meeting at a vertex.
| Param | Type | Description |
|-------|------|-------------|
| `vertex_x, vertex_y` | `float` | Angle vertex point |
| `start_x, start_y` | `float` | First line endpoint |
| `end_x, end_y` | `float` | Second line endpoint |
| `dim_x, dim_y` | `float` | Dimension arc position |
| `layer` | `str \| null` | Target layer |
## dimension_radius
Radius dimension for circles and arcs.
| Param | Type | Description |
|-------|------|-------------|
| `center_x, center_y` | `float` | Circle/arc center |
| `radius` | `float` | Radius value |
| `angle` | `float` | Leader angle in degrees (where arrow points) |
| `layer` | `str \| null` | Target layer |
## dimension_diameter
Diameter dimension across a circle.
| Param | Type | Description |
|-------|------|-------------|
| `x1, y1` | `float` | First point on diameter |
| `x2, y2` | `float` | Opposite point on diameter |
| `layer` | `str \| null` | Target layer |
@@ -1,103 +0,0 @@
# Drawing Management Tools (11)
## Contents
- [drawing_info](#drawing_info)
- [drawing_new](#drawing_new)
- [drawing_open](#drawing_open)
- [drawing_save](#drawing_save)
- [drawing_save_as](#drawing_save_as)
- [drawing_export_dxf](#drawing_export_dxf)
- [drawing_export_pdf](#drawing_export_pdf)
- [drawing_purge](#drawing_purge)
- [drawing_audit](#drawing_audit)
- [drawing_undo / drawing_redo](#drawing_undo--drawing_redo)
---
## drawing_info
Read-only. Returns comprehensive metadata for the current drawing.
**Returns**: `{name, full_path, saved, entity_count, layer_count, block_count, extents_min, extents_max, units, version, backend}`
---
## drawing_new
Create a new empty drawing, optionally from a template.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `template` | `str \| null` | No | Path to `.dwt` template file |
Path validation applied to template. Returns drawing info dict.
---
## drawing_open
Open an existing DWG or DXF file. Reports progress.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | `str` | Yes | Full path to `.dwg` or `.dxf` file |
Path validation: `allow_write=False`. Returns drawing info dict.
---
## drawing_save
Save current drawing. Uses current path if omitted.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | `str \| null` | No | Optional save path |
Path validation: `allow_write=True`.
---
## drawing_save_as
Save drawing to a new path.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | `str` | Yes | Full destination path with extension |
Path validation: `allow_write=True`.
---
## drawing_export_dxf
Export drawing as DXF file.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | `str` | Yes | Output `.dxf` file path |
| `version` | `str` | No | DXF version: `R2010`, `R2013`, `R2018` (default: `R2018`) |
---
## drawing_export_pdf
Export drawing as PDF. Requires `matplotlib`.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | `str` | Yes | Output `.pdf` file path |
---
## drawing_purge
Remove all unused objects (layers, blocks, linetypes, styles). No parameters.
---
## drawing_audit
Run integrity audit — detect and fix drawing errors. No parameters.
---
## drawing_undo / drawing_redo
Undo/redo last operation.
- **ezdxf**: Snapshot-based (restores full DXF state from `_undo_stack`)
- **COM**: Native AutoCAD undo/redo
**Note**: For multi-step undo protection, use `transaction_begin/rollback` instead.
@@ -1,96 +0,0 @@
# Entity Creation Tools (13)
All creation tools return `{handle, type, layer, color, linetype, visible, properties}`. The `handle` (hex string) is required for all subsequent operations on that entity.
Common optional params on all creation tools:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `layer` | `str` | Current layer | Target layer name |
| `color` | `int` | 256 (ByLayer) | ACI color (1-255, 256=ByLayer, 0=ByBlock) |
| `linetype` | `str` | `"ByLayer"` | Linetype name |
---
## entity_create_line
| Param | Type | Description |
|-------|------|-------------|
| `x1, y1` | `float` | Start point |
| `x2, y2` | `float` | End point |
## entity_create_circle
| Param | Type | Description |
|-------|------|-------------|
| `cx, cy` | `float` | Center point |
| `radius` | `float` | Radius |
## entity_create_arc
| Param | Type | Description |
|-------|------|-------------|
| `cx, cy` | `float` | Center point |
| `radius` | `float` | Radius |
| `start_angle` | `float` | Start angle (degrees, CCW from X) |
| `end_angle` | `float` | End angle (degrees) |
## entity_create_polyline
| Param | Type | Description |
|-------|------|-------------|
| `points` | `list[list[float]]` | `[[x,y], [x,y], ...]` coordinate pairs |
| `closed` | `bool` | Close the polyline (default: false) |
## entity_create_rectangle
| Param | Type | Description |
|-------|------|-------------|
| `x1, y1` | `float` | First corner |
| `x2, y2` | `float` | Opposite corner |
Creates an LWPOLYLINE (closed).
## entity_create_text
| Param | Type | Description |
|-------|------|-------------|
| `text` | `str` | Text content |
| `x, y` | `float` | Insertion point |
| `height` | `float` | Text height (default: 2.5) |
| `rotation` | `float` | Rotation angle in degrees (default: 0) |
## entity_create_mtext
| Param | Type | Description |
|-------|------|-------------|
| `text` | `str` | Content (`\P` for paragraph break, `{\H...;}` for formatting) |
| `x, y` | `float` | Insertion point |
| `width` | `float` | Text box width (default: 100) |
| `height` | `float` | Character height (default: 2.5) |
## entity_create_hatch
| Param | Type | Description |
|-------|------|-------------|
| `pattern` | `str` | Pattern name: `SOLID`, `ANSI31`, `ANSI32`, `STEEL`, `GRAVEL`, etc. |
| `boundary_points` | `list[list[float]]` | Closed boundary polygon `[[x,y], ...]` |
| `scale` | `float` | Pattern scale (default: 1.0) |
| `angle` | `float` | Pattern angle (default: 0) |
## entity_create_spline
| Param | Type | Description |
|-------|------|-------------|
| `fit_points` | `list[list[float]]` | Points the spline passes through |
## entity_create_ellipse
| Param | Type | Description |
|-------|------|-------------|
| `cx, cy` | `float` | Center point |
| `major_axis_x, major_axis_y` | `float` | Major axis endpoint (relative to center) |
| `ratio` | `float` | Minor/major axis ratio (0.0–1.0) |
## entity_create_point
| Param | Type | Description |
|-------|------|-------------|
| `x, y` | `float` | Point coordinates |
## entity_create_block_ref
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Block definition name (must exist) |
| `x, y` | `float` | Insertion point |
| `x_scale, y_scale` | `float` | Scale factors (default: 1.0) |
| `rotation` | `float` | Rotation in degrees (default: 0) |
@@ -1,111 +0,0 @@
# Entity Modification & Query Tools (13)
All modify tools require a `handle` (hex string). Get handles from `entity_list()` or any `entity_create_*` return value.
---
## Modification Tools (10)
### entity_move
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity handle |
| `dx, dy` | `float` | Displacement vector |
### entity_copy
Returns new entity info with a **new handle**.
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity to copy |
| `dx, dy` | `float` | Offset for the copy |
### entity_rotate
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity handle |
| `base_x, base_y` | `float` | Rotation base point |
| `angle_degrees` | `float` | Rotation angle (CCW) |
### entity_scale
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity handle |
| `base_x, base_y` | `float` | Scale base point |
| `factor` | `float` | Scale factor |
### entity_mirror
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity handle |
| `x1, y1` | `float` | Mirror line point 1 |
| `x2, y2` | `float` | Mirror line point 2 |
### entity_offset
Works on line, circle, polyline.
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity handle |
| `distance` | `float` | Offset distance (positive = outward) |
### entity_delete
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity to delete |
### entity_array_rectangular
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity to array |
| `rows` | `int` | Number of rows |
| `cols` | `int` | Number of columns |
| `row_spacing` | `float` | Row spacing |
| `col_spacing` | `float` | Column spacing |
Returns list of new entity handles.
### entity_array_polar
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity to array |
| `center_x, center_y` | `float` | Array center point |
| `count` | `int` | Number of copies |
| `angle` | `float` | Total angle to fill (degrees, default: 360) |
### entity_set_properties
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity handle |
| `layer` | `str \| null` | Move to layer |
| `color` | `int \| null` | Change ACI color |
| `linetype` | `str \| null` | Change linetype |
| `lineweight` | `float \| null` | Change lineweight |
All property params are optional — only provided ones are changed.
---
## Query Tools (3)
### entity_get
| Param | Type | Description |
|-------|------|-------------|
| `handle` | `str` | Entity handle |
Returns full entity info: `{handle, type, layer, color, linetype, visible, properties}`.
### entity_list
| Param | Type | Description |
|-------|------|-------------|
| `type_filter` | `str \| null` | Filter: `LINE`, `CIRCLE`, `ARC`, `LWPOLYLINE`, `TEXT`, `MTEXT`, `INSERT`, `HATCH`, etc. |
| `layer_filter` | `str \| null` | Filter by layer name |
| `limit` | `int` | Max results (default: 100) |
| `offset` | `int` | Skip first N results (default: 0) |
### entity_delete_many
| Param | Type | Description |
|-------|------|-------------|
| `handles` | `list[str]` | List of handles to delete |
Reports progress. Returns `{deleted, failed, errors}`.
@@ -1,63 +0,0 @@
# Layer Management Tools (12)
Layers organize entities by category (walls, doors, dimensions, etc.). Each layer has: name, color (ACI), linetype, lineweight, and states (on/off, frozen/thawed, locked/unlocked).
---
## layer_list
No parameters. Returns list of all layers with properties: `{name, color, linetype, lineweight, is_on, is_frozen, is_locked, is_current}`.
## layer_create
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | New layer name |
| `color` | `int` | ACI color (default: 7=white) |
| `linetype` | `str` | Linetype name (default: `"Continuous"`) |
## layer_delete
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Layer to delete (must be empty, cannot be layer "0") |
## layer_set_current
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Layer to make current (new entities go here) |
## layer_modify
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Layer to modify |
| `color` | `int \| null` | New color |
| `linetype` | `str \| null` | New linetype |
| `lineweight` | `float \| null` | New lineweight |
## layer_freeze / layer_thaw
Freeze hides and excludes from regeneration (faster). Thaw restores.
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Layer name |
## layer_lock / layer_unlock
Lock prevents editing but keeps visible. Unlock restores editing.
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Layer name |
## layer_hide / layer_show
Hide turns layer off (invisible). Show turns it on.
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Layer name |
## layer_isolate
Shows only the specified layer, hides all others.
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Layer to keep visible |
To restore: `layer_show` each hidden layer, or reopen the drawing.
@@ -1,95 +0,0 @@
# View, Transaction & System Tools (14)
## View & Screenshot (5)
### view_zoom_extents
Zoom to show all entities. No parameters. **COM only** (no-op in ezdxf).
### view_zoom_window
Zoom to a specific rectangular area. **COM only**.
| Param | Type | Description |
|-------|------|-------------|
| `x1, y1` | `float` | Window corner 1 |
| `x2, y2` | `float` | Window corner 2 |
### view_screenshot
Capture drawing as image. Returns base64-encoded image.
- **COM**: Win32 window capture (requires Pillow)
- **ezdxf**: matplotlib render (requires matplotlib)
### view_zoom_and_screenshot
Combined zoom + screenshot in one call.
| Param | Type | Description |
|-------|------|-------------|
| `x1, y1` | `float \| null` | Optional zoom window corner 1 |
| `x2, y2` | `float \| null` | Optional zoom window corner 2 |
If coordinates omitted, captures full extents.
### view_set_view
Set named view. Parameters depend on backend.
---
## Transactions (3)
Use transactions to group operations with undo support.
```
transaction_begin() # Save checkpoint
... make changes ...
transaction_commit() # Accept changes
# or
transaction_rollback() # Revert to checkpoint
```
### transaction_begin
Start a transaction. ezdxf saves full DXF snapshot; COM creates undo mark.
### transaction_commit
Accept all changes since `transaction_begin`.
### transaction_rollback
Revert all changes since `transaction_begin`. ezdxf restores snapshot; COM uses native undo.
---
## System (6)
### system_status
No parameters. Returns server status: `{backend, drawing_loaded, entity_count, uptime, ...}`.
### system_get_variable
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Variable name: `DIMSCALE`, `LTSCALE`, `INSUNITS`, `CLAYER`, `MEASUREMENT` |
### system_set_variable
| Param | Type | Description |
|-------|------|-------------|
| `name` | `str` | Variable name |
| `value` | `any` | New value |
### system_run_command
Execute raw AutoCAD command. **COM only**. Sanitized by `sanitize_command()`.
| Param | Type | Description |
|-------|------|-------------|
| `command` | `str` | Command string (e.g. `_ZOOM E`, `_REGEN`) |
Dangerous commands blocked unless `DANGEROUS_COMMANDS_ENABLED=true`.
### system_run_lisp
Evaluate AutoLISP expression. **COM only**. Sanitized by `sanitize_lisp()`.
| Param | Type | Description |
|-------|------|-------------|
| `expression` | `str` | LISP expression (e.g. `(command "ZOOM" "E")`) |
Dangerous functions blocked: `startapp`, `vl-file-delete`, `dos_*`.
### system_about
No parameters. Returns server capabilities, tool count, version, and feature list.
@@ -1,79 +0,0 @@
# Troubleshooting
## Common Errors
### "AutoCAD backend unavailable"
**Cause**: No drawing is loaded — backend needs an active drawing context.
**Fix**: Call `drawing_new()` or `drawing_open(path)` before any other operations.
### "Entity handle 'XXXX' not found"
**Cause**: Handle doesn't exist, was deleted, or drawing was reloaded.
**Fix**: Use `entity_list()` to get current valid handles. Handles are session-specific in ezdxf; persist by saving/reopening.
### "Layer 'X' not found"
**Cause**: Layer doesn't exist in the drawing.
**Fix**: `layer_list()` to see existing layers. `layer_create("X")` to create it.
### "Cannot delete layer '0'"
**Cause**: Layer "0" is the default layer and cannot be removed.
**Fix**: Move entities to another layer instead. Layer "0" always exists.
### "Path validation failed"
**Cause**: Path contains traversal (`..`) or is outside `ALLOWED_PATHS`.
**Fix**: Use absolute paths within configured allowed directories. Check `ALLOWED_PATHS` env var.
### "Command rejected by sanitizer"
**Cause**: `system_run_command` detected a dangerous pattern (ERASE, DELETE, etc.).
**Fix**: Use specific tools instead (e.g., `entity_delete` instead of `ERASE` command). Or set `DANGEROUS_COMMANDS_ENABLED=true` if needed.
### "LISP expression rejected"
**Cause**: `system_run_lisp` detected dangerous function (startapp, file ops).
**Fix**: Use MCP tools instead of LISP for file operations.
### "COM backend initialization failed"
**Cause**: AutoCAD not running, not installed, or COM registration issues.
**Fix**: Start AutoCAD, then retry. Or switch to ezdxf: `AUTOCAD_MCP_BACKEND=ezdxf`.
### "View operations are no-ops"
**Cause**: Using ezdxf backend — view/zoom commands only work with COM.
**Fix**: Switch to COM backend if live viewport control is needed. Screenshots still work via matplotlib in ezdxf.
### "Screenshot failed"
**Cause**: Missing dependency (Pillow for COM, matplotlib for ezdxf).
**Fix**: `pip install Pillow` (COM) or `pip install matplotlib` (ezdxf).
### "PDF export failed"
**Cause**: matplotlib not installed.
**Fix**: `pip install matplotlib` or `pip install -e ".[pdf]"`.
### "Transaction not active"
**Cause**: `transaction_commit/rollback` called without `transaction_begin`.
**Fix**: Always call `transaction_begin()` first.
### "Block 'X' not found"
**Cause**: Block definition doesn't exist.
**Fix**: `block_list()` to see available blocks. Create a block with `block_create_from_entities()`.
---
## Backend-Specific Issues
### ezdxf Backend
- **No DWG write support**: ezdxf reads DWG but can only write DXF. Use `drawing_export_dxf()`.
- **Undo stack memory**: Full DXF snapshots stored. `MAX_UNDO_STACK` env limits count (default: 50).
- **Text rendering**: Depends on system fonts. MathTex/complex formatting may not render in screenshots.
### COM Backend
- **STA threading**: All COM calls go through a single thread. Batch operations are sequential.
- **AutoCAD version**: Tested with AutoCAD 2020+. Older versions may have COM API differences.
- **Window capture**: Screenshots capture the AutoCAD window as-is. Minimize overlapping windows.
---
## Diagnostic Steps
1. `system_status()` — Check backend type, connection, drawing state
2. `system_about()` — See available tools and capabilities
3. `drawing_info()` — Verify drawing is loaded and get metadata
4. `analysis_entity_stats()` — Quick overview of drawing contents
5. Check logs: `LOG_LEVEL=DEBUG` for verbose output
@@ -1,166 +0,0 @@
# Common Workflows
## 1. New Drawing from Scratch
```
Progress:
- [ ] Step 1: Create drawing
- [ ] Step 2: Set up layers
- [ ] Step 3: Draw entities
- [ ] Step 4: Add dimensions
- [ ] Step 5: Add text annotations
- [ ] Step 6: Verify and save
```
**Step 1**: `drawing_new()` — creates empty drawing
**Step 2**: Create layers for organization:
```
layer_create("WALLS", color=7, linetype="Continuous")
layer_create("DOORS", color=3)
layer_create("WINDOWS", color=5)
layer_create("DIMENSIONS", color=1)
layer_create("TEXT", color=2)
```
Or use `template_apply_layers("architectural")` for standard sets.
**Step 3**: Draw geometry on appropriate layers:
```
layer_set_current("WALLS")
entity_create_rectangle(x1=0, y1=0, x2=6000, y2=4000)
entity_create_line(x1=3000, y1=0, x2=3000, y2=4000)
```
**Step 4**: Add dimensions:
```
layer_set_current("DIMENSIONS")
dimension_linear(x1=0, y1=0, x2=6000, y2=0, dim_x=3000, dim_y=-500)
```
**Step 5**: Add text labels:
```
layer_set_current("TEXT")
entity_create_text(text="Living Room", x=1500, y=2000, height=200)
```
**Step 6**: Verify and save:
```
analysis_entity_stats() # check entity counts
validation_check() # run quality checks
drawing_save("/path/to/drawing.dxf")
```
---
## 2. Edit Existing DXF
```
Progress:
- [ ] Step 1: Open file
- [ ] Step 2: Explore contents
- [ ] Step 3: Make changes (with transaction)
- [ ] Step 4: Verify and save
```
**Step 1**: `drawing_open("/path/to/file.dxf")`
**Step 2**: Explore:
```
analysis_entity_stats() # overview
layer_list() # see layers
entity_list(type_filter="LINE", limit=20) # browse entities
```
**Step 3**: Begin transaction, then modify:
```
transaction_begin()
entity_move(handle="1A2B", dx=100, dy=0)
entity_set_properties(handle="3C4D", color=1, layer="WALLS")
entity_delete(handle="5E6F")
transaction_commit() # or transaction_rollback() to undo all
```
**Step 4**: `drawing_save()`
---
## 3. Batch Entity Creation
```
Progress:
- [ ] Step 1: Prepare entity list
- [ ] Step 2: Batch create
- [ ] Step 3: Verify results
```
**Step 1**: Build entity definitions:
```json
[
{"type": "line", "x1": 0, "y1": 0, "x2": 100, "y2": 0, "layer": "WALLS"},
{"type": "line", "x1": 100, "y1": 0, "x2": 100, "y2": 100, "layer": "WALLS"},
{"type": "circle", "cx": 50, "cy": 50, "radius": 10, "layer": "DETAILS"},
{"type": "text", "text": "A1", "x": 50, "y": -20, "height": 5, "layer": "TEXT"}
]
```
**Step 2**: `entity_batch_create(entities=[...])` — returns handles for all created entities
**Step 3**: `analysis_entity_stats()` to verify counts
---
## 4. Drawing Analysis
```
Progress:
- [ ] Step 1: Open drawing
- [ ] Step 2: Get overview stats
- [ ] Step 3: Examine specific areas
- [ ] Step 4: Measure and report
```
**Step 1**: `drawing_open(path)` or `drawing_info()` if already open
**Step 2**:
```
analysis_entity_stats() # by type and layer
analysis_layer_stats() # per-layer details
analysis_bounding_box() # drawing extents
```
**Step 3**: Region queries:
```
analysis_find_in_region(x1=0, y1=0, x2=1000, y2=1000, type_filter="LINE")
analysis_select_by_layer(layer_name="WALLS")
analysis_select_by_type(entity_type="CIRCLE")
```
**Step 4**: Measurements:
```
analysis_measure_distance(x1=0, y1=0, x2=100, y2=100)
analysis_measure_area(points=[[0,0],[100,0],[100,100],[0,100]])
```
---
## 5. Template-Based Drawing
```
Progress:
- [ ] Step 1: Create drawing with template
- [ ] Step 2: Apply layer standards
- [ ] Step 3: Add content
- [ ] Step 4: Validate and save
```
**Step 1**: `drawing_new(template="/path/to/template.dwt")` (or `drawing_new()` without template)
**Step 2**: `template_apply_layers("architectural")` — creates standard layers
**Step 3**: Draw content on appropriate layers
**Step 4**:
```
validation_check(["empty_layers", "zero_length"])
drawing_save("/path/to/output.dxf")
```
@@ -1,79 +0,0 @@
"""Check AutoCAD MCP Pro setup and dependencies."""
import importlib
import os
import sys
def check(name: str, condition: bool, fix: str = ""):
status = "OK" if condition else "MISSING"
symbol = "[+]" if condition else "[-]"
line = f" {symbol} {name}: {status}"
if not condition and fix:
line += f" -> {fix}"
print(line)
return condition
def main():
print("AutoCAD MCP Pro — Setup Check\n")
ok_count = 0
total = 0
print("Python:")
total += 1
v = sys.version_info
ok_count += check(
f"Python {v.major}.{v.minor}.{v.micro}",
v >= (3, 11),
"Requires Python 3.11+",
)
print("\nRequired packages:")
for pkg, install in [
("fastmcp", "pip install fastmcp"),
("ezdxf", "pip install ezdxf"),
("pydantic", "pip install pydantic"),
]:
total += 1
try:
importlib.import_module(pkg)
ok_count += check(pkg, True)
except ImportError:
ok_count += check(pkg, False, install)
print("\nOptional packages:")
for pkg, install, purpose in [
("win32com", "pip install pywin32", "COM backend"),
("PIL", "pip install Pillow", "Screenshots (COM)"),
("matplotlib", "pip install matplotlib", "PDF export + screenshots (ezdxf)"),
]:
total += 1
try:
importlib.import_module(pkg)
ok_count += check(f"{pkg} ({purpose})", True)
except ImportError:
ok_count += check(f"{pkg} ({purpose})", False, install)
print("\nEnvironment variables:")
for var, default in [
("AUTOCAD_MCP_BACKEND", "auto"),
("ALLOWED_PATHS", "(empty = all paths allowed)"),
("MAX_UNDO_STACK", "50"),
("LOG_LEVEL", "INFO"),
("DANGEROUS_COMMANDS_ENABLED", "false"),
]:
val = os.environ.get(var, "")
total += 1
ok_count += check(var, True, "")
if val:
print(f" Current value: {val}")
else:
print(f" Default: {default}")
print(f"\nResult: {ok_count}/{total} checks passed")
return 0 if ok_count >= 4 else 1
if __name__ == "__main__":
sys.exit(main())
@@ -1,91 +0,0 @@
"""Validate drawing quality — run after creating/editing a drawing.
Usage: python scripts/validate_drawing.py [drawing_path]
This script checks for common drawing issues:
- Empty layers (layers with no entities)
- Entities on layer "0" (should use named layers)
- Entity count summary
"""
import json
import sys
def validate_stats(stats: dict) -> list[str]:
"""Validate entity statistics. Pass the result of analysis_entity_stats()."""
issues = []
if stats.get("total", 0) == 0:
issues.append("WARNING: Drawing has no entities")
by_layer = stats.get("by_layer", {})
if "0" in by_layer and by_layer["0"] > 0:
issues.append(
f"INFO: {by_layer['0']} entities on default layer '0' "
"(consider moving to named layers)"
)
return issues
def validate_layers(layers: list[dict]) -> list[str]:
"""Validate layer configuration. Pass the result of layer_list()."""
issues = []
for layer in layers:
name = layer.get("name", "")
if name == "0":
continue
if layer.get("color", 7) == 7 and name != "0":
issues.append(
f"INFO: Layer '{name}' uses default color (white/7). "
"Consider assigning a distinct color."
)
return issues
def main():
print("Drawing Validation Tool")
print("=" * 40)
print()
print("This tool validates output from MCP analysis tools.")
print("Pipe JSON from analysis_entity_stats() or layer_list().")
print()
print("Usage with MCP:")
print(" 1. Call analysis_entity_stats() → copy JSON output")
print(" 2. Call layer_list() → copy JSON output")
print(" 3. Review results for issues")
print()
print("Checks performed:")
print(" - Empty drawing detection")
print(" - Entities on default layer '0'")
print(" - Layers with default color")
print()
if not sys.stdin.isatty():
try:
data = json.load(sys.stdin)
if "total" in data:
issues = validate_stats(data)
elif isinstance(data, list):
issues = validate_layers(data)
else:
issues = ["ERROR: Unrecognized input format"]
if issues:
for issue in issues:
print(f" {issue}")
else:
print(" OK — No issues found")
except json.JSONDecodeError:
print(" ERROR: Invalid JSON input")
else:
print("No stdin input. Run interactively or pipe JSON data.")
return 0
if __name__ == "__main__":
sys.exit(main())
-4
View File
@@ -9,10 +9,6 @@ on:
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# NOTE: non-blocking until the repo-wide ruff cleanup lands (tracked for the
# quality pass). The test job below is the real gate. Once the codebase is
# ruff-clean, drop `continue-on-error` to make lint blocking again.
continue-on-error: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
+1 -2
View File
@@ -1,11 +1,10 @@
"""AutoCAD MCP Pro – backend package.""" """AutoCAD MCP Pro – backend package."""
from .base import AutoCADBackend, BlockInfo, CommandResult, DrawingInfo, EntityInfo, LayerInfo from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo
from .com_backend import ComBackend from .com_backend import ComBackend
from .ezdxf_backend import EzdxfBackend from .ezdxf_backend import EzdxfBackend
__all__ = [ __all__ = [
"AutoCADBackend", "AutoCADBackend",
"CommandResult",
"EntityInfo", "EntityInfo",
"LayerInfo", "LayerInfo",
"BlockInfo", "BlockInfo",
+13 -54
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import math import math
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, TYPE_CHECKING from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from engineering.plan_spec import ( from engineering.plan_spec import (
@@ -23,21 +23,6 @@ if TYPE_CHECKING:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass
class CommandResult:
ok: bool
payload: Any = None
error: str | None = None
def to_dict(self) -> dict:
d: dict[str, Any] = {"ok": self.ok}
if self.ok:
d["payload"] = self.payload
else:
d["error"] = self.error
return d
@dataclass @dataclass
class EntityInfo: class EntityInfo:
handle: str handle: str
@@ -91,7 +76,7 @@ class DrawingInfo:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def normalize_lineweight(lw: "float | int | None") -> "int | None": def normalize_lineweight(lw: float | int | None) -> int | None:
"""Coerce a lineweight to ezdxf/COM hundredths-of-a-mm integers. """Coerce a lineweight to ezdxf/COM hundredths-of-a-mm integers.
AutoCAD and ezdxf store lineweights as integer hundredths (25 == 0.25 mm), AutoCAD and ezdxf store lineweights as integer hundredths (25 == 0.25 mm),
@@ -548,17 +533,17 @@ class AutoCADBackend(ABC):
# behaviour. The only backend-specific primitive is `_create_xline` — XLINE # behaviour. The only backend-specific primitive is `_create_xline` — XLINE
# has no cross-backend equivalent among the standard entity_create_* tools. # has no cross-backend equivalent among the standard entity_create_* tools.
_plan_spec: "PlanSpec | None" = None _plan_spec: PlanSpec | None = None
async def drawing_plan( async def drawing_plan(
self, intent: str, self, intent: str,
sheet_size: "SheetSize" = "A3", sheet_size: SheetSize = "A3",
scale: float = 1.0, scale: float = 1.0,
layer_set_id: "LayerSetId" = "mech", layer_set_id: LayerSetId = "mech",
view_count: int = 1, view_count: int = 1,
dim_style: "DimStyle" = "chain", dim_style: DimStyle = "chain",
notes: list[str] | None = None, notes: list[str] | None = None,
) -> "PlanSpec": ) -> PlanSpec:
"""Commit a drawing intent before any geometry is created. """Commit a drawing intent before any geometry is created.
Returns a PlanSpec held on the backend for `drawing_critique`.""" Returns a PlanSpec held on the backend for `drawing_critique`."""
from engineering.plan_spec import PlanSpec from engineering.plan_spec import PlanSpec
@@ -575,15 +560,15 @@ class AutoCADBackend(ABC):
return plan return plan
async def drawing_critique( async def drawing_critique(
self, focus: list["CritiqueFocus"] | None = None, self, focus: list[CritiqueFocus] | None = None,
) -> list["Issue"]: ) -> list[Issue]:
"""Run premium-quality checks; return zero issues before """Run premium-quality checks; return zero issues before
`drawing_finalize`. `focus=None` runs all checks.""" `drawing_finalize`. `focus=None` runs all checks."""
from engineering.critique import run_critique from engineering.critique import run_critique
return await run_critique(self, focus) return await run_critique(self, focus)
async def point_from_snap( async def point_from_snap(
self, handle: str, snap: "SnapType", self, handle: str, snap: SnapType,
ref_x: float | None = None, ref_y: float | None = None, ref_x: float | None = None, ref_y: float | None = None,
) -> tuple[float, float]: ) -> tuple[float, float]:
"""Return a deterministic snap point on `handle`. """Return a deterministic snap point on `handle`.
@@ -769,7 +754,7 @@ class AutoCADBackend(ABC):
return {"ok": True, "layer": layer, "deleted": deleted} return {"ok": True, "layer": layer, "deleted": deleted}
async def drawing_apply_iso_layers( async def drawing_apply_iso_layers(
self, standard: "LayerSetId" = "mech", self, standard: LayerSetId = "mech",
) -> dict: ) -> dict:
"""Bootstrap a full ISO-conformant layer set with correct colors """Bootstrap a full ISO-conformant layer set with correct colors
and lineweights. Idempotent.""" and lineweights. Idempotent."""
@@ -778,7 +763,7 @@ class AutoCADBackend(ABC):
return {"ok": True, "standard": standard, "layers": result} return {"ok": True, "standard": standard, "layers": result}
async def dimension_auto( async def dimension_auto(
self, handles: list[str], style: "DimStyle" = "chain", self, handles: list[str], style: DimStyle = "chain",
offset: float = 10.0, offset: float = 10.0,
) -> list[EntityInfo]: ) -> list[EntityInfo]:
"""Generate ISO 129 dimensions across `handles` in the chosen style """Generate ISO 129 dimensions across `handles` in the chosen style
@@ -828,7 +813,7 @@ class AutoCADBackend(ABC):
dx, dy = ex0 - sx0, ey0 - sy0 dx, dy = ex0 - sx0, ey0 - sy0
L0 = math.hypot(dx, dy) or 1.0 L0 = math.hypot(dx, dy) or 1.0
ux, uy = dx / L0, dy / L0 ux, uy = dx / L0, dy / L0
for i, (sx, sy, ex, ey) in enumerate(segs): for i, (_sx, _sy, ex, ey) in enumerate(segs):
dim = await self.dimension_linear( dim = await self.dimension_linear(
sx0, sy0, ex, ey, sx0, sy0, ex, ey,
sx0 - uy * (off + i * off), sy0 + ux * (off + i * off), sx0 - uy * (off + i * off), sy0 + ux * (off + i * off),
@@ -906,32 +891,6 @@ class AutoCADBackend(ABC):
return [e for e in ents if _ok(e)] return [e for e in ents if _ok(e)]
# ── concrete helpers (use existing primitives) ──────────────────────────
async def set_layer_active(self, name: str) -> None:
"""Convenience: alias for layer_set_current. Concrete; backends need not override."""
await self.layer_set_current(name)
async def ensure_linetypes(
self, names: list[str], file: str | None = None,
) -> dict[str, str]:
"""Idempotently load each linetype in `names`. Returns {name: status}.
Status is one of: 'already_loaded', 'loaded', 'failed: <msg>'.
"""
try:
current = {ln.lower() for ln in await self.linetype_list()}
except Exception:
current = set()
results: dict[str, str] = {}
for n in names:
if n.lower() in current:
results[n] = "already_loaded"
continue
try:
await self.linetype_load(n, file=file)
results[n] = "loaded"
except Exception as exc:
results[n] = f"failed: {exc}"
return results
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -7
View File
@@ -360,7 +360,6 @@ class ComBackend(AutoCADBackend):
def __init__(self): def __init__(self):
self._executor: ThreadPoolExecutor | None = None self._executor: ThreadPoolExecutor | None = None
self._batch_mode = False
self._connected = False self._connected = False
self._transaction_active = False self._transaction_active = False
@@ -413,7 +412,7 @@ class ComBackend(AutoCADBackend):
if timeout > 0: if timeout > 0:
return await asyncio.wait_for(future, timeout=timeout) return await asyncio.wait_for(future, timeout=timeout)
return await future return await future
except asyncio.TimeoutError as e: except TimeoutError as e:
log.error("COM call timed out after %.1fs; rebuilding executor", timeout) log.error("COM call timed out after %.1fs; rebuilding executor", timeout)
# The worker thread is still blocked inside SendCommand and cannot be # The worker thread is still blocked inside SendCommand and cannot be
# cancelled. Abandon it (shutdown(wait=False)) and start a fresh # cancelled. Abandon it (shutdown(wait=False)) and start a fresh
@@ -656,7 +655,7 @@ class ComBackend(AutoCADBackend):
def _sync(): def _sync():
mspace = _msp() mspace = _msp()
# acPatternTypePreDefined = 0 # acPatternTypePreDefined = 0
hatch = mspace.AddHatch(0, pattern, True) hatch = mspace.AddHatch(0, pattern, False)
hatch.PatternScale = float(scale) hatch.PatternScale = float(scale)
hatch.PatternAngle = deg2rad(angle) hatch.PatternAngle = deg2rad(angle)
# Build outer loop as a temporary lwpolyline # Build outer loop as a temporary lwpolyline
@@ -886,7 +885,6 @@ class ComBackend(AutoCADBackend):
self, handle, rows, cols, row_spacing, col_spacing, self, handle, rows, cols, row_spacing, col_spacing,
) -> list[EntityInfo]: ) -> list[EntityInfo]:
def _sync(): def _sync():
self._batch_mode = True
_COM_STATE["batch_mode"] = True _COM_STATE["batch_mode"] = True
try: try:
doc = _acad_doc() doc = _acad_doc()
@@ -904,7 +902,6 @@ class ComBackend(AutoCADBackend):
entities.append(_entity_info(result)) entities.append(_entity_info(result))
return entities return entities
finally: finally:
self._batch_mode = False
_COM_STATE["batch_mode"] = False _COM_STATE["batch_mode"] = False
_regen() _regen()
return await self._run(_sync) return await self._run(_sync)
@@ -913,7 +910,6 @@ class ComBackend(AutoCADBackend):
self, handle, count, fill_angle, center_x, center_y, self, handle, count, fill_angle, center_x, center_y,
) -> list[EntityInfo]: ) -> list[EntityInfo]:
def _sync(): def _sync():
self._batch_mode = True
_COM_STATE["batch_mode"] = True _COM_STATE["batch_mode"] = True
try: try:
doc = _acad_doc() doc = _acad_doc()
@@ -930,7 +926,6 @@ class ComBackend(AutoCADBackend):
entities.append(_entity_info(result)) entities.append(_entity_info(result))
return entities return entities
finally: finally:
self._batch_mode = False
_COM_STATE["batch_mode"] = False _COM_STATE["batch_mode"] = False
_regen() _regen()
return await self._run(_sync) return await self._run(_sync)
+38 -19
View File
@@ -51,11 +51,6 @@ except ImportError:
# Helper utilities # Helper utilities
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_ACI_NAMES = {
1: "Red", 2: "Yellow", 3: "Green", 4: "Cyan",
5: "Blue", 6: "Magenta", 7: "White", 256: "ByLayer", 0: "ByBlock",
}
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
# AutoCAD-shipped linetypes that ezdxf.tools.standards does not include. # AutoCAD-shipped linetypes that ezdxf.tools.standards does not include.
@@ -344,12 +339,17 @@ class EzdxfBackend(AutoCADBackend):
return await self._async(_sync) return await self._async(_sync)
async def drawing_save_as(self, path: str, fmt: str = "dxf") -> dict: async def drawing_save_as(self, path: str, fmt: str = "dxf") -> dict:
if fmt.lower() == "dwg":
raise RuntimeError(
"ezdxf backend cannot write true DWG binary. "
"Use save_path with a .dxf extension, or switch to the COM backend."
)
def _sync(): def _sync():
doc = self._require_doc() doc = self._require_doc()
doc.saveas(path) doc.saveas(path)
self._doc_path = path self._doc_path = path
self._dirty = False self._dirty = False
return {"ok": True, "path": path, "format": fmt} return {"ok": True, "path": path, "format": "dxf"}
return await self._async(_sync) return await self._async(_sync)
async def drawing_export_dxf(self, path: str) -> dict: async def drawing_export_dxf(self, path: str) -> dict:
@@ -693,7 +693,7 @@ class EzdxfBackend(AutoCADBackend):
dim = msp.add_aligned_dim( dim = msp.add_aligned_dim(
p1=(float(x1), float(y1)), p1=(float(x1), float(y1)),
p2=(float(x2), float(y2)), p2=(float(x2), float(y2)),
dist=math.sqrt((float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2), distance=math.sqrt((float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2),
) )
dim.render() dim.render()
ent = dim.dimension ent = dim.dimension
@@ -708,11 +708,12 @@ class EzdxfBackend(AutoCADBackend):
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
msp = self._msp() msp = self._msp()
vxf, vyf = float(vx), float(vy)
dim = msp.add_angular_dim_2l( dim = msp.add_angular_dim_2l(
center=(float(vx), float(vy)), base=(float(tx), float(ty)),
p1=(float(x1), float(y1)), line1=((vxf, vyf), (float(x1), float(y1))),
p2=(float(x2), float(y2)), line2=((vxf, vyf), (float(x2), float(y2))),
distance=10.0, location=(float(tx), float(ty)),
) )
dim.render() dim.render()
ent = dim.dimension ent = dim.dimension
@@ -875,11 +876,19 @@ class EzdxfBackend(AutoCADBackend):
d = float(distance) d = float(distance)
if ent_type == "LINE": if ent_type == "LINE":
# Offset a line: move parallel # Offset a line: move parallel; side_x/side_y selects which side
start = Vec2(ent.dxf.start.x, ent.dxf.start.y) start = Vec2(ent.dxf.start.x, ent.dxf.start.y)
end = Vec2(ent.dxf.end.x, ent.dxf.end.y) end = Vec2(ent.dxf.end.x, ent.dxf.end.y)
direction = (end - start).normalize() direction = (end - start).normalize()
normal = Vec2(-direction.y, direction.x) * d left_normal = Vec2(-direction.y, direction.x)
# Determine sign: positive = left side (default); flip if side point is on right
sign = 1.0
if side_x is not None and side_y is not None:
mid = (start + end) * 0.5
side_vec = Vec2(float(side_x), float(side_y)) - mid
if left_normal.dot(side_vec) < 0:
sign = -1.0
normal = left_normal * (d * sign)
new_start = start + normal new_start = start + normal
new_end = end + normal new_end = end + normal
msp = self._msp() msp = self._msp()
@@ -893,7 +902,13 @@ class EzdxfBackend(AutoCADBackend):
elif ent_type == "CIRCLE": elif ent_type == "CIRCLE":
cx, cy = ent.dxf.center.x, ent.dxf.center.y cx, cy = ent.dxf.center.x, ent.dxf.center.y
new_r = ent.dxf.radius + d # side_x/side_y selects inside (shrink) or outside (grow); default = outside
sign = 1.0
if side_x is not None and side_y is not None:
dist_to_center = math.sqrt((float(side_x) - cx) ** 2 + (float(side_y) - cy) ** 2)
if dist_to_center < ent.dxf.radius:
sign = -1.0
new_r = ent.dxf.radius + d * sign
if new_r <= 0: if new_r <= 0:
raise ValueError("Offset distance too large for circle") raise ValueError("Offset distance too large for circle")
msp = self._msp() msp = self._msp()
@@ -944,8 +959,12 @@ class EzdxfBackend(AutoCADBackend):
from ezdxf.math import Matrix44 from ezdxf.math import Matrix44
results = [] results = []
cx, cy = float(center_x), float(center_y) cx, cy = float(center_x), float(center_y)
step = math.radians(float(fill_angle)) / max(int(count) - 1, 1) fa = float(fill_angle)
for i in range(1, int(count)): n = int(count)
# 360° full circle: n copies evenly spaced, no duplicate at 0°
divisor = n if abs(fa % 360.0) < 1e-6 else max(n - 1, 1)
step = math.radians(fa) / divisor
for i in range(1, n):
copy = ent.copy() copy = ent.copy()
msp.add_entity(copy) msp.add_entity(copy)
angle = step * i angle = step * i
@@ -1535,7 +1554,7 @@ class EzdxfBackend(AutoCADBackend):
async def entity_trim(self, target_handle, cutter_handle, keep_x, keep_y) -> EntityInfo: async def entity_trim(self, target_handle, cutter_handle, keep_x, keep_y) -> EntityInfo:
def _sync(): def _sync():
from ezdxf.math import intersection_line_line_2d, Vec2 from ezdxf.math import Vec2, intersection_line_line_2d
target = self._get_entity(target_handle) target = self._get_entity(target_handle)
cutter = self._get_entity(cutter_handle) cutter = self._get_entity(cutter_handle)
if target.dxftype() != "LINE" or cutter.dxftype() != "LINE": if target.dxftype() != "LINE" or cutter.dxftype() != "LINE":
@@ -1574,7 +1593,7 @@ class EzdxfBackend(AutoCADBackend):
self, target_handle, boundary_handle, end_x=None, end_y=None, self, target_handle, boundary_handle, end_x=None, end_y=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
from ezdxf.math import intersection_line_line_2d, Vec2 from ezdxf.math import Vec2, intersection_line_line_2d
target = self._get_entity(target_handle) target = self._get_entity(target_handle)
boundary = self._get_entity(boundary_handle) boundary = self._get_entity(boundary_handle)
if target.dxftype() != "LINE" or boundary.dxftype() != "LINE": if target.dxftype() != "LINE" or boundary.dxftype() != "LINE":
@@ -1616,7 +1635,7 @@ class EzdxfBackend(AutoCADBackend):
"""Shared geometry setup for fillet/chamfer. """Shared geometry setup for fillet/chamfer.
Returns (line1, line2, P, d1, d2, A_far, B_far, theta) where d1/d2 are Returns (line1, line2, P, d1, d2, A_far, B_far, theta) where d1/d2 are
unit vectors from intersection P toward the far endpoint of each line.""" unit vectors from intersection P toward the far endpoint of each line."""
from ezdxf.math import intersection_line_line_2d, Vec2 from ezdxf.math import Vec2, intersection_line_line_2d
line1 = self._get_entity(handle1) line1 = self._get_entity(handle1)
line2 = self._get_entity(handle2) line2 = self._get_entity(handle2)
if line1.dxftype() != "LINE" or line2.dxftype() != "LINE": if line1.dxftype() != "LINE" or line2.dxftype() != "LINE":
+209
View File
@@ -0,0 +1,209 @@
The lineweight truncation bug is confirmed devastating: ENGINEERING_LAYERS uses floats like 0.50, 0.25, 0.18, 0.13, 0.05 mm, and `layer_create` does `int(lineweight)` (ezdxf_backend.py:1034), so every one floors to 0. Counts confirmed: 109 tools, 6 resources, 5 prompts; LICENSE/benchmarks/ci.yml all absent. All load-bearing claims verified against source. I have everything needed to synthesize.
# AutoCAD MCP Pro — Understanding & Research Dossier
## 1. Architecture at a Glance
AutoCAD MCP Pro is a **single-file FastMCP 3.0 server** (`server.py`, 2881 lines) exposing **109 `@mcp.tool` registrations, 6 `@mcp.resource` endpoints, 5 `@mcp.prompt` templates** (verified by grep, not the docs' "87"). It uses a **strategy-pattern dual backend** behind one ABC (`backends/base.py`, ~73 abstract methods + 5 dataclasses): a **live COM backend** (`com_backend.py`, pywin32, single STA-thread `ThreadPoolExecutor`) and a **headless ezdxf backend** (`ezdxf_backend.py`, file-based DXF, all sync work via `asyncio.to_thread` under one `asyncio.Lock`). Backend selection (`server.py::_make_backend`, server.py:69): env `AUTOCAD_MCP_BACKEND`; Windows `auto`/`com` tries COM then falls back to ezdxf; non-Windows always ezdxf.
Request path: `ErrorHandlingMiddleware → AuditMiddleware → TimingMiddleware → LoggingMiddleware` (server.py:197-200) → tool fn → `_backend(ctx)` (server.py:207, raises `ToolError` if backend None) → identically-named backend method → `_dc()` dataclass→dict serialization (server.py:216). Lifespan (`autocad_lifespan`, server.py:107) builds the backend singleton into `ctx.lifespan_context["backend"]`.
A **deterministic engineering layer** (`engineering/` package) backs the "premium" workflow: parametric gear/keyway/titleblock primitives, a closed-enum `drawing_critique`, an 8-step `DrawingValidator`, ISO layer/linetype scaffolds, and a `PlanSpec` plan-before-draw contract. **The central architectural fact: the premium workflow is fully implemented only on ezdxf; the COM backend stubs all 8 premium meta-tools with `NotImplementedError` (com_backend.py:1637-1660, verified) — yet COM is the default Windows backend.**
---
## 2. Per-Subsystem Summary
**`server.py` (transport + tool registry).** Thin async wrappers over the backend singleton, grouped into 13 sections (1–12 + sub-sections 8b/8c/8d batch/template/validation + 13 premium). Owns server-side composition tools (`entity_delete_many` server.py:1065, `layer_isolate` :1216, `block_find_references` :1362, `analysis_layer_stats` :1506, `entity_batch_*` :1538/:1590, `template_*` :1695/:1730, `validation_check` :1749). Entry point manually parses `--transport/--port/--host` (server.py:2866) and refuses non-loopback HTTP unless `ALLOW_REMOTE_HTTP` + `MCP_AUTH_TOKEN` (`_validate_http_bind`).
**`backends/base.py` (the contract).** `AutoCADBackend` ABC: ~73 `@abstractmethod`, 2 concrete (`set_layer_active`, `ensure_linetypes`), 5 dataclasses (`EntityInfo/LayerInfo/BlockInfo/DrawingInfo/CommandResult`), 3 helpers (`shoelace_area`, `deg2rad`, `rad2deg`). Both backends define a body for every abstract method (no structural gaps); divergences are behavioral.
**`backends/ezdxf_backend.py` (headless).** Full create/modify/query/analysis/dimension/block/layer + premium meta-tools + corner ops. Transactions = full DXF snapshot to tempfile on `_undo_stack` (trimmed to `max_undo_stack=5`, O(drawing-size) per `transaction_begin`). View ops and `system_run_command/run_lisp` are documented no-ops. Screenshot/PDF via matplotlib.
**`backends/com_backend.py` (live AutoCAD).** All COM through one STA worker with per-call timeout (`com_call_timeout`, default 60s) and executor-rebuild on timeout/fatal HRESULT. Corner ops via `_safe_send_command` (CMDACTIVE-polling). Win32 `PrintWindow` screenshots. **8 premium meta-tools + `block_create_from_entities` are stubs.**
**`engineering/` (deterministic CAD).** `gear.py` (involute spur/helical outlines), `keyway.py` (DIN 6885), `titleblock.py` (ISO 7200 A3), `layers.py` (mech/pid/iso13567 sets), `critique.py` (6 focuses), `validator.py` (8-step gate), `plan_spec.py` (PlanSpec/Issue + ISO-128 lineweight set). `section.py` (`cutting_plane_line`, `apply_section_hatch`) and `gear.generate_tooth_profile` are **dead code** (exported, zero callers/tests).
**`tests/` (184 functions, 14 files).** One ezdxf-only `backend` fixture (`conftest.py`). COM backend, server→sanitizer wiring, and `section.py` have **zero coverage**.
---
## 3. Complete MCP Tool Inventory (grouped; ✅ real / ⚠️ partial / ❌ stub-or-missing)
> All 109 tools are **registered** in `server.py` and have **real ezdxf implementations**. "Status" below reflects the **COM backend** (the Windows default) plus correctness caveats. Nothing is purely aspirational/unregistered.
| § | Tools | ezdxf | COM | Notes |
|---|---|---|---|---|
| 1 Drawing (12, doc says 11) | info, new, open, save, save_as, export_dxf, export_pdf, purge, audit, close, undo, redo | ✅ (redo ❌ no-op ezdxf_backend.py:487) | ✅ | `drawing_redo` inert on ezdxf; header says 11 but 12 registered (drawing_redo server.py:426) |
| 2 Entity Create (13) | line, circle, arc, polyline, rectangle(→polyline), text, mtext, hatch, spline, ellipse, point, block_ref | ✅ | ✅ | mtext has no rotation param on ezdxf (:578); hatch pattern-only single loop |
| 3 Dimensions (5) | linear, aligned, angular, radius, diameter | ⚠️ aligned/angular **xfail** (tests/test_dimensions.py:16); angular ignores tx/ty + fixed dist=10 (ezdxf:705) | ✅ | No dimstyle/tolerance support either backend |
| 4 Modify (14) | move, copy, rotate, scale, mirror, offset, trim, extend, fillet, chamfer, delete, array_rectangular, array_polar, set_properties | ⚠️ offset ignores side (:868); corner ops LINE+LINE only (:1535-1750); polar 360° dup (:946) | ⚠️ offset ignores side (:860); corner ops via SendCommand | |
| 5 Query (3) | entity_get, entity_list, entity_delete_many | ✅ | ✅ | COM entity_list = O(n) per-item COM reads (:967) |
| 6 Layer (14) | list, create, delete, set_current, modify, freeze, thaw, lock, unlock, hide, show, isolate, linetype_list, linetype_load | ⚠️ **lineweight int() truncation** (:1034) | ✅ | See RISK-2 |
| 7 Block (7) | list, insert, explode, get_attributes, set_attributes, create_from_entities, find_references | ✅ | ⚠️ create_from_entities ❌ stub (:1253) | BlockInfo.description never populated |
| 8 Analysis (8) | stats, find_in_region, measure_distance, measure_area, bounding_box, select_by_layer, select_by_type, layer_stats | ⚠️ region fallback insert/start only (:1330) | ✅ | hand-rolled loops vs ezdxf query/groupby |
| 8b Batch (2) | entity_batch_create, entity_batch_modify | ✅ | ✅ | |
| 8c Template (2) | template_apply_layers, template_list | ✅ | ✅ | Duplicates `_LAYER_TEMPLATES`/prompt layer sets |
| 8d Validation (1) | validation_check | ✅ | ✅ | |
| 9 View (4, doc says 5) | zoom_extents, zoom_window, screenshot, zoom_and_screenshot | ⚠️ zoom = no-op (:1394) | ✅ | Header says 5 |
| 10 Transactions (3) | begin, commit, rollback | ✅ (commit file-I/O outside lock :1452) | ⚠️ flag-leak on error (:1402) | |
| 11 System (6) | status, get_variable, set_variable, run_command, run_lisp, about | ⚠️ run_command/lisp no-op | ⚠️ run_lisp result always 'nil' (:1487); about catalog drifted | |
| 12 Engineering (7) | gear_draw_helical/spur/section_aa, keyway_draw_keyed_bore/section, titleblock_apply_iso_a3, drawing_finalize | ✅ | ✅ (use generic entity_create_*) | Validator/titleblock import-guarded → None risk |
| 13 Premium (8) | drawing_plan, drawing_critique, point_from_snap, construction_xline, construction_clear, drawing_apply_iso_layers, dimension_auto, entity_select_smart | ✅ | ❌ **all NotImplementedError** (:1637-1660) | **The documented-mandatory workflow is dead on COM** |
**Documented-but-missing entirely:** none. **Documented-as-working but stubbed on default backend:** the 8 premium tools (§13) + `block_create_from_entities` on COM.
---
## 4. Cross-Cutting RISK Register (merged, deduped, severity-sorted)
| # | Sev | Risk | Location | Evidence |
|---|---|---|---|---|
| R1 | **CRITICAL** | All 8 premium meta-tools `raise NotImplementedError` on COM (the Windows default). The "non-negotiable for production" workflow (`drawing_plan→apply_iso_layers→critique→finalize`) 500s on the primary platform. | com_backend.py:1637-1660 | **Verified verbatim**; ezdxf impls real (ezdxf_backend.py:1753-2050) |
| R2 | **CRITICAL** | `layer_create` does `int(lineweight)` so every sub-1.0 ISO mm value (0.50/0.25/0.18/0.13/0.05) floors to **0**. The entire ISO-128 lineweight discipline is silently nulled; `drawing_apply_iso_layers`/`drawing_new` bootstrap produce flat 0-weight layers. | ezdxf_backend.py:1034; values engineering/layers.py:16-26 | **Both verified.** ezdxf wants integer hundredths (50=0.50mm), not raw mm |
| R3 | **HIGH** | `_check_iso128` is a permanent no-op: `lw <= 0: continue` (critique.py:50) skips every layer made 0 by R2. The advertised ISO gate can never fire. Comment "the layer dataclass already normalises to mm" (critique.py:53) is **false** (ezdxf_backend.py:189 stores raw `float(lw)`). | engineering/critique.py:37-64 | **Verified verbatim** |
| R4 | **HIGH** | `_check_dim_overlap` reads `properties['text_position'/'defpoint'/'insertion_point']`, but the ezdxf DIM extractor sets only `dim_type` (ezdxf_backend.py:166-168) → `dim_points` always empty → check is a silent no-op. | engineering/critique.py:194-197 | — |
| R5 | **HIGH** | COM timeout path (`stuck.shutdown(wait=False)`) cannot kill the STA worker still blocked in SendCommand → **permanently leaks one CoInitialize'd thread + COM proxy per timeout**; no `CoUninitialize` anywhere. | com_backend.py:415-433, 68-71 | — |
| R6 | **HIGH** | Zero COM-backend test coverage (~80 methods incl. UndoMark transactions, SendCommand corner ops, CMDACTIVE guard). Passing ezdxf transaction tests give **false confidence** for the semantically different COM model. | tests/ (no ComBackend import); conftest.py:8-15 | — |
| R7 | **HIGH** | Server→sanitizer wiring untested: `test_security.py` tests `sanitize_command/lisp/validate_path` in isolation; no integration test proves `system_run_command/lisp` actually call them. A refactor dropping the call (server.py:2035/2052) passes all security tests while raw commands reach SendCommand. | server.py:2035,2052; tests/test_security.py | — |
| R8 | **HIGH** | LISP-aliasing bypass (`(setq f command)(f "ERASE")`) is claimed-defended in a comment (security.py:138-140) but has **no adversarial test**; UNC/`~`/symlink path escapes in `validate_path` also untested. | security.py:138-179; test_security.py:177-231 | — |
| R9 | **MED** | `entity_array_polar` full-360° fill double-places: `step=radians(360)/(count-1)` puts the last copy at 0°==original → duplicate. Hits the common bolt-circle/gear pattern the engineering layer relies on. | ezdxf_backend.py:946-947 | — |
| R10 | **MED** | `drawing_finalize` calls `drawing_save_as(str(validated))` with no `fmt`. ezdxf default `fmt='dxf'` (ezdxf:345) vs COM `fmt='dwg'` (com:515) vs save_as tool default `'dwg'` (server:336) → 3-way inconsistency; format backend-dependent, not path-derived. | server.py:2299 | **Verified** (omits fmt) |
| R11 | **MED** | `drawing_save_as(fmt='dwg')` on ezdxf writes DXF bytes to a `.dwg` path and returns `{'format':'dwg'}` — silent wrong-format file. ezdxf cannot write DWG; should error or use `odafc`. | ezdxf_backend.py:345-352 | — |
| R12 | **MED** | `dimension_angular` ignores tx/ty, fixed `distance=10.0` → arc radius wrong for large/small parts; breaks COM parity. | ezdxf_backend.py:705-722 | — |
| R13 | **MED** | `entity_offset` ignores `side_x/side_y` (both backends); only `distance` sign flips side; ARC/LWPOLYLINE/ELLIPSE raise "not supported". | ezdxf:868-908; com:860-874 | — |
| R14 | **MED** | Engineering titleblock/validator symbols import-guarded to `None` (engineering/__init__.py:40-51); if module truly fails to import, tools raise opaque "NoneType not callable" instead of clear ToolError. | server.py:2266,2294 | — |
| R15 | **MED** | `system_about` static `tool_groups` catalog drifted: omits all 15 engineering+premium tools, misfiles `entity_delete_many`. Agents inspecting capabilities can't see the CLAUDE.md-mandated tools. | server.py:2070-2106 | — |
| R16 | **MED** | COM `transaction_active` left stale-True if commit/rollback `_run` raises; `rollback` issues `EndUndoMark`+`UNDO B` SendCommand without the CMDACTIVE guard used elsewhere → can interleave mid-command. | com_backend.py:1402,1411-1421 | — |
| R17 | **MED** | `_safe_send_command` busy-polls CMDACTIVE with `time.sleep(0.05)` on the single STA thread → monopolizes the executor up to deadline; `deadline_s`(8s) decoupled from `_run` timeout(60s). | com_backend.py:1521-1536 | — |
| R18 | **MED** | `drawing_new` swallows bootstrap failures into `result['bootstrap']={'error':...}` and reports success → un-bootstrapped drawing looks normal though CLAUDE.md treats the scaffold as a precondition for gear_*/titleblock_*. | server.py:280-294 | — |
| R19 | **MED** | `dimension_aligned`/`dimension_angular` permanently `xfail(strict=False)` — advertised tools shipped broken; an accidental fix wouldn't alert. | tests/test_dimensions.py:16-27 | — |
| R20 | LOW | `_registered_tool_count` reaches into FastMCP privates (`mcp._local_provider._components`, `'tool:'` prefix); a minor upgrade → tool_count=-1 surfaced to clients. | server.py:231-234 | — |
| R21 | LOW | Involute flank prepends a straight radial root→base segment (no trochoid fillet) → geometrically wrong dedendum for `root_r<base_r`; root-arc gap when `root_r>=base_r`; degenerate arc for teeth≤2. | engineering/gear.py:40-50,163-190 | — |
| R22 | LOW | `drawing_undo`/`transaction_rollback` share one `_undo_stack` with no transaction-id/depth separation; reload doesn't reset `_doc_path`/`_current_layer`. | ezdxf_backend.py:470-484,1462-1476 | — |
| R23 | LOW | COM `system_run_lisp` always reports `result='nil'` (SendCommand returns None); no CMDACTIVE guard → a prompting LISP form deadlocks next call. | com_backend.py:1487-1492 | — |
| R24 | LOW | `_apply_attrs` sets `entity.dxf.linetype` without `_ensure_linetype_loaded` (unlike set_properties/layer_modify) → `linetype='HIDDEN'` on create may render Continuous or raise on save. | ezdxf_backend.py:491-501 | — |
| R25 | LOW | `block_find_references` calls `entity_list(type_filter='INSERT')` with no limit (bypasses `max_list_limit`) → unbounded INSERTs into memory; assumes every backend populates `properties['block_name']`. | server.py:1362-1370 | — |
| R26 | LOW | View zoom ops unconditionally registered but no-op on ezdxf → success response implies the view changed when it didn't; `view_zoom_and_screenshot` may not frame the requested region on ezdxf. | server.py:1814-1831,1870 | — |
| R27 | LOW | `validator.py` title check substring-matches 'SPUR'/'HELICAL SPUR' on TEXT layer → false-positive blocks `drawing_finalize` if any note contains 'SPUR'. | engineering/validator.py:166-181 | — |
| R28 | LOW | COM `_find_autocad_hwnd` grabs first visible window whose title contains 'AutoCAD' → may capture palette/splash/About dialog; not tied to `AcadApplication.HWND`. | com_backend.py:283-300 | — |
| R29 | LOW | Broad `except Exception: pass` swallows COM faults (disconnected/busy) at debug level in ~20 sites → bypasses the HRESULT reconnect logic in `_run`. | com_backend.py:199,244,868,901,1216,… | — |
---
## 5. Doc-vs-Code Drift Table
| Claim (source) | Reality | Verdict |
|---|---|---|
| "87 tools" (CLAUDE.md, README:6) | **109** `@mcp.tool` (verified) | STALE (soft — both add "count is dynamic" caveat) |
| "104 tests" (README:83) | **184** test functions across 14 files (verified) | STALE (~80 under) |
| Section 1 "(11 tools)" (CLAUDE.md) | 12 (`drawing_redo` server.py:426) | WRONG |
| Section 9 "(5 tools)" (CLAUDE.md) | 4 view tools registered | WRONG |
| "12-section taxonomy" (CLAUDE.md) | 1–12 + 8b/8c/8d + 13 = 6 extra groups | INCOMPLETE |
| Premium workflow "non-negotiable for production" (CLAUDE.md) | 8/8 premium tools dead on COM (default Windows backend) | OVERCLAIM |
| "MIT — see [LICENSE](LICENSE)" (README:409) | No LICENSE file (verified absent) | **DEAD LINK / legal drift** |
| `benchmarks/` referenced (README:39,302,367) | Absent (verified) | DEAD LINK (forward-looking) |
| ".github/workflows/ci.yml" + "GitHub Actions CI" (README:84,358) | git status `D`; absent on disk (verified) | **CI REMOVED while docs claim it** |
| "Premium discipline lives in `.claude/skills/autocad-mcp-premium/`" (CLAUDE.md) | Entire tree git-deleted (28 deletions) + all `.cursor/` deleted | DANGLING POINTER |
| README tests/ tree (README:347-355) | Omits 6 of 14 test files + engineering/ package | STALE |
| `system_about` tool_groups | Omits 15 engineering+premium tools | DRIFT |
| `BlockInfo.description` field (base.py:71) | Never populated by either backend | DEAD FIELD |
| critique.py:53 "normalises to mm" comment | Stores raw DXF lineweight (ezdxf:189) | FALSE COMMENT |
| `engineering/__init__.py:37-39` "Agent B … in flight"; com_backend "Task #7/#8/#9" | Multi-agent build seams shipped unreconciled | TODO-IN-PROD |
| Dockerfile, .env.example (README) | Present (verified) | OK |
---
## 6. Test Coverage Gaps (ranked)
1. **COM backend: 0% coverage** (com_backend.py entire file) — transactions (UndoMark vs ezdxf snapshot are *different* semantics), SendCommand corner ops, CMDACTIVE guard, timeout/HRESULT recovery, all 8 premium stubs. Highest-risk untested surface; it's the production path. (R6)
2. **Server→sanitizer integration** — no test proves tools invoke `sanitize_command/lisp`/`validate_path`. (R7)
3. **Sanitizer adversarial cases** — LISP aliasing, command word-boundary evasion (`.ERASE`), UNC/`~`/symlink path escapes — the comment claims defenses with no tests. (R8)
4. **Critique detection paths** — `iso128`, `layer_color`, `dim_overlap` only exercised on a *clean* drawing (assert they *don't* fire); their actual detection logic and the unknown-focus branch (critique.py:241) are never proven. (R3, R4)
5. **`dimension_auto` ordinate** (ezdxf:1988) untested; chain/baseline covered.
6. **`dimension_aligned`/`angular`** permanently xfail — shipped broken. (R19)
7. **Uncovered backend methods (both backends):** `system_get/set_variable`, `system_status`, `drawing_close`, `drawing_export_pdf`, `entity_create_block_ref`, `block_set_attributes`, `linetype_list/load` (load-bearing for bootstrap), `view_zoom_*`, `view_screenshot`.
8. **`engineering/section.py`** (dead code) — 0 coverage.
9. **Transaction nesting/error semantics** — only single begin→commit/rollback tested; nested, rollback-without-begin, layer/block restoration untested.
10. **Infrastructure:** CI workflow deleted (no PR gate); `pytest-cov` declared but no `--cov`/`fail-under`; several "template/validation" tests assert nothing about the named feature (vacuous `if start:` guards, test_batch_template.py:30-34).
---
## 7. Research Synthesis
### 7a. Academic techniques (2025–2026) that validate or upgrade this design
- **GeoGramBench (ICLR 2026, arxiv.org/abs/2505.17653):** frontier LLMs score **<50%** at high-abstraction Program-to-Geometry. → Empirically justifies the repo's no-coordinate-guessing rule and `point_from_snap`/`entity_select_smart`. **Action:** expand the deterministic geometry surface (`point_intersection`, `point_offset`, `point_along`) so the LLM *never* computes coordinates.
- **Self-Improving CAD w/ FEA feedback (arxiv.org/html/2605.17448v2):** deterministic controller owns validation, LLM owns design; **+13.4pp** per feedback round; frontier models get **zero** first-attempt strict passes — *iteration beats more tokens*. → This is the repo's architecture, but `drawing_critique` is one-shot. **Action:** make it a routed repair loop; `Issue` (plan_spec.py:64-81) already carries severity/handles/detail — add per-finding repair hints.
- **CADSmith (arxiv.org/abs/2603.26512):** 5-agent Planner/Coder/Executor/Validator/**Refiner**, dual loop, exact-kernel measurements + **independent VLM judge** → Chamfer 28.37mm→0.74mm (**38×**), IoU 0.81→0.96; *removing the rendered image alone made hard cases 35× worse.* → The repo renders a screenshot at finalize (server.py:2301-2309) but only checks bytes exist (validator step 7). **Highest-leverage single upgrade: feed that render to a VLM judge + add an automated Refiner.**
- **CADFusion (ICML 2025, arxiv.org/abs/2501.19054)** & **Seek-CAD (arxiv.org/abs/2505.17702):** render is a first-class signal; step-wise visual feedback catches errors mid-build. → Add a `visual` critique focus; run critique per construction step using the existing transaction model.
- **ProCAD "Clarify Before You Draw" (arxiv.org/html/2602.03045v1):** one-round clarification → invalidity 14.6%→0.9%, Chamfer ~80% lower. → `drawing_plan` records intent passively; add a pre-plan ambiguity/conflict detector that returns targeted questions before any `entity_create_*`.
- **FutureCAD (arxiv.org/abs/2603.11831):** NL → exact B-Rep primitive grounding fixes the fillet/chamfer "which primitive" problem. → Directly targets the repo's hardest API: trim/extend/fillet/chamfer need exact handles **plus** keep_x/keep_y. NL grounding ("fillet the top-left corner") removes the burden GeoGramBench shows LLMs fail at.
- **Text-to-CadQuery (2505.06507), CAD-Coder (2505.14646), EvoCAD (2510.11631):** field converged on Python/CadQuery macros; open local VLMs read CAD images reliably; candidate-generate + critique-as-fitness raises first-try quality. → Add more parametric meta-tools (the gear/keyway pattern) and/or a sandboxed parametric-script tool; an open VLM (CAD-Coder, **Florence-2 for GD&T**, arxiv.org/abs/2411.03707) keeps the visual tier off frontier APIs.
- **MUSE (arxiv.org/html/2605.28579) + CadBench (2605.10873):** grade manufacturability/functionality, not just shape; standard metrics = volumetric/surface IoU, Chamfer, **Invalidity Ratio**. → Replace `validator.py`'s regex/title heuristics with a staged code→geometric→rubric-judge protocol; emit a single scalar "drawing score" for regression tests (the repo has *no* quantitative quality metric).
### 7b. Competitive landscape
| Product | 3D | Render-critique loop | Validation gate | HTTP auth | Code escape hatch |
|---|---|---|---|---|---|
| **AutoCAD MCP Pro** | ❌ 2D only | ⚠️ finalize-only, symbolic | ✅ **unique** (critique+validator+ISO) | ❌ none | ⚠️ COM-only run_command/lisp |
| build123d-mcp/agentcad (github.com/pzfreo/build123d-mcp) | ✅ | ✅ in-loop | ❌ | — | ✅ |
| FreeCAD Robust MCP (150+ tools) | ✅ + FEM | ⚠️ | ❌ | — | ✅ |
| Blender MCP (~22k★) | ✅ | ✅ | ❌ | — | ✅ Python exec |
| RhinoMCP | ✅ | ✅ viewport | ❌ | — | ✅ |
| Fusion360/Onshape MCP | ✅ parametric | — | ❌ | ✅ OAuth/keys | — |
Snyk's 9-CAD-MCP roundup (snyk.io/articles/9-mcp-servers-for-computer-aided-drafting-cad-with-ai/): **vision only in FreeCAD/Blender/Rhino; design validation in none; HTTP auth only in Onshape/Fusion.** → **Our moat is the design-rule validation gate; the field beat us on 3D, in-loop render-critique, and auth.** Autodesk's own in-AutoCAD assistant demo (aecmag.com/features/autodesk-shows-its-ai-hand/) flagship = **automated compliance checking** — exactly our niche, so we must make standards compliance the sharpest, most-cited capability. Adam/Zoo (techcrunch.com/2025/10/31/...) set the UX bar: **parametric editability + post-gen dimension sliders + entity-level conversational editing**.
### 7c. Under-used COM / ezdxf API + ISO standards
**COM (ActiveX) gaps (every create call hits ModelSpace, com_backend.py:125-127):**
- **PaperSpace/Layouts + Plot config** — `drawing_export_pdf` (com_backend.py:528-534) calls bare `Plot.PlotToFile(path,'DWG To PDF.pc3')` with no page setup → ignores the A3/scale `drawing_plan` promised. Add `layout_*`/`viewport_*` (AddPViewport, StandardScale, CanonicalMediaName). (help.autodesk.com ActiveX ref)
- **AddTable** (BOM/revision tables), **AddMLeader** (ISO leader notes — CLAUDE.md *reserves* "leader notes" but no tool creates them), **Fields** (auto-updating title block), **GetDynamicBlockProperties** (parametric symbol libraries — `block_insert` only does attribute text, com:1195-1251), **DimStyles.Add+CopyFrom** (no ISO-129 dimstyle is ever set → undercuts the "Dimensions are ISO 129" rule), **named UCS** (per-view frames for `view_count`), **AttachExternalReference** (shared borders), **gradient/island hatch**.
- **3D solids/regions/booleans** — `system_status` advertises `all_entity_types` (com:1443) which is **false**; either implement `solid_*` or correct the claim.
- **Push selection into AutoCAD:** `entity_list` (com:967) and `analysis_select_by_type` (com:1352) loop ModelSpace.Item(i) in Python (O(n) COM round-trips — the #1 perf killer); `analysis_select_by_layer` (com:1339) already shows the right `SelectionSet.SelectAll` group-code-filter pattern to copy.
**ezdxf gaps (Grep confirms zero use of `set_gradient/set_solid_fill/paperspace/add_viewport/odafc/export_dwg/groupby/dimstyles/MTextEditor/add_ordinate/qsave`):**
- **`addons.odafc.export_dwg`** = real DWG (R12–R2018) → fixes R11/R10 (drawing_save_as fake DWG). (ezdxf.mozman.at/docs/addons/odafc.html)
- **drawing-addon `Configuration`** (lineweight_policy ABSOLUTE, background_policy, SVGBackend/PyMuPdfBackend) → true ISO lineweights + vector output, replacing raw matplotlib (ezdxf:357-379,1400-1424).
- **`query()` mini-language + `groupby()`** → replace hand-rolled loops in analysis_stats/select/entity_select_smart (which pulls 5000 entities then filters in memory, ezdxf:2008-2050).
- **`recover.readfile` + `Auditor.fix`** → real repair; `drawing_audit` (ezdxf:448) reports but never fixes; `drawing_open` (ezdxf:309) has no recover fallback.
- **`MTextEditor`** (stacked tolerance fractions), **Hatch gradients/islands/edge-paths**, **DimStyle overrides** (no ISO dimstyle today, ezdxf:668-774), **path-module offset** (generalizes LINE/CIRCLE-only offset + LINE-only corner ops).
**ISO standards conformance:**
- ✅ **ISO 128-2** lineweight set encoded (plan_spec.py:85, verified) — but **nulled by R2** and group-vs-element-class not checked.
- ⚠️ **ISO 7200** title block — A3-only hardcoded (titleblock.py:16-17), informal labels ("DWG NO" vs ISO field names), len×0.3 centering heuristic (titleblock.py:171), missing "Document type"/"Date of issue".
- ❌ **ISO 129-1 dimensioning** — biggest functional gap: `dimension_*` (base.py:226-257) have **no** param for ±/limit/fit (H7/g6) tolerances or text override → toleranced production dims impossible. (iso.org/obp/ui/#iso:std:iso:129)
- ❌ **ISO 1101 / ASME Y14.5 GD&T** — entirely absent (grep-confirmed): no feature control frame, datum, or ISO 2768 general-tolerance note. (gdandtbasics.com/iso-vs-asme-standards/)
- ⚠️ **ISO 13567** — static mech-only starter list (layers.py:51-62), no field-grammar parser/validator.
---
## 8. "What Would Make This the BEST AutoCAD MCP" — Prioritized Thesis
The evidence converges: **the repo is architecturally ahead on dual-backend + standards-validation but behind on the closed-loop, the production-output plumbing, and the very workflow it markets.** Priorities, evidence-backed:
**P0 — Make the marketed product actually work on its default platform.**
1. **Implement the 8 premium meta-tools on COM** (R1, com_backend.py:1637-1660). Today the CLAUDE.md-mandatory workflow 500s on every Windows install. Nothing else matters until this ships.
2. **Fix the lineweight `int()` truncation** (R2, ezdxf_backend.py:1034 → multiply mm by 100 / map to ACAD enum). This single bug nullifies the ISO-128 system *and* makes the iso128 critique a permanent no-op (R3). The standards-validation gate is the **competitive moat** (Snyk roundup; Autodesk's own flagship demo) — it must be real.
3. **Add CI + a coverage gate + the first COM smoke tests** (R6, ci.yml deleted; pytest-cov unconfigured). Restore the LICENSE file (legal drift).
**P1 — Own the moat by closing the loop (the #1 accuracy lever in every 2025–26 paper).**
4. **Turn `drawing_critique` into a routed iterative Refiner loop** with per-`Issue` repair hints and a bounded budget (CADSmith Refiner; FEA-agent +13.4pp/round; Physics-in-the-Loop 3.3–6.0 iters converge). The `Issue` dataclass already carries handles+detail.
5. **Promote the finalize screenshot to a first-class `visual` critique focus fed to a VLM judge** (CADSmith: dropping the render → 35× worse; CADFusion). Use an **open local VLM** (CAD-Coder / Florence-2 for GD&T) behind a `[vision]` extra so the headless path is unaffected. Add a **dimension round-trip check** (VLM-read dims vs created `dimension_*` entities) to catch what proximity-only `dim_overlap` (R4) cannot.
6. **Add a ProCAD-style pre-plan clarification pass to `drawing_plan`** (invalidity 14.6%→0.9%): detect missing/conflicting dims, material, tolerance, view_count-vs-intent before any geometry.
7. **Emit a scalar "drawing score" / Invalidity-Ratio** (MUSE/CadBench) so the premium workflow is regression-testable — the repo has no quantitative accuracy metric.
**P2 — Reach production-drawing parity (what un-toleranced ISO 128 can't deliver).**
8. **ISO 129 tolerances + ISO 1101/Y14.5 GD&T**: add ±/limit/fit params to `dimension_*` and a `gd_frame(symbol, tol, datums, modifiers)` + datum/ISO-2768-note primitive. This is the categorical gap blocking real part drawings; no surveyed text-to-CAD product covers 2D GD&T — a differentiator.
9. **Real plotted sheets**: COM Layout/PaperSpace/Plot-config so `drawing_plan(sheet_size, scale)` and `titleblock_apply_iso_a3` produce a correct A-series plot; on ezdxf wire `odafc.export_dwg` (kills the fake-DWG R11) and the drawing-addon `Configuration` for true-lineweight SVG/PDF.
10. **An ISO-129 `DimStyle`** created once and applied to all dims (COM `DimStyles.Add+CopyFrom`; ezdxf dimstyle overrides) so "Dimensions are ISO 129" becomes true, and **MLEADER** for the leader notes the docs already reserve.
**P3 — Catch the field on breadth (table-stakes elsewhere, absent here).**
11. **A 3D solids tier** (extrude/revolve from 2D profiles + booleans on COM; STEP/STL on ezdxf) — *or* correct the false `all_entity_types` claim (com:1443). Every competitor has 3D.
12. **Parametric editability + entity-level conversational editing** (Adam/Zoo): capture driving dims in PlanSpec, expose a re-drive tool; "select this circle → make it the 20mm bore" via existing handle model + `entity_select_smart`/`point_from_snap`.
13. **Authenticated remote HTTP** (API-key middleware) given `dangerous_commands_enabled` exists (server.py:111); a **standard-parts/symbol library** (`library_insert` over `block_insert`, dynamic-block props); **NL→B-Rep grounding** (FutureCAD) over the brittle trim/fillet/chamfer keep-point API.
**P4 — Hygiene that compounds with the above.** Generalize LINE-only corner ops + LINE/CIRCLE-only offset via the ezdxf path module; replace hand-rolled selection with `query()`/`groupby()` (ezdxf) and `SelectionSet.SelectAll` filters (COM); fix `entity_array_polar` 360° dup (R9), `dimension_angular` tx/ty (R12), the COM thread-leak `CoUninitialize` (R5); delete dead `section.py`/`generate_tooth_profile`; reconcile `system_about` catalog (R15) and the stale README/CLAUDE.md drift (§5).
**Files central to every priority:** `backends/com_backend.py:1637-1660` (P0.1), `backends/ezdxf_backend.py:1034` + `engineering/layers.py:14-26` (P0.2), `engineering/critique.py:37-64,194-197` (P0.2/P1.5), `engineering/plan_spec.py:64-87` (P1.4/P1.6/P2.8), `backends/base.py:226-257` (P2.8), `server.py:528-534/2294-2309` (P2.9).
+309
View File
@@ -0,0 +1,309 @@
<!-- Generated by the DIAGNOSTICS workflow (wf_c3e5770d-2cf), 2026-06-18.
Verified: 31 | Confirmed: 28 | Refuted/fixed: 2 | New from sweep: 17.
NOTE: R7/R8 (sanitizer wiring + LISP-aliasing/path-traversal defensive tests) were
NOT auto-verified — the security subagents hit a provider cyber-safety filter.
They are tracked for manual defensive review during the hardening work item. -->
# DIAGNOSTICS Report — AutoCAD MCP Pro
Synthesized from the verified recon risk register + fresh-sweep findings. REFUTED / ALREADY-FIXED items are excluded from the ranked body and listed in the appendix. Duplicates between the two sources are merged. Line numbers reflect **current** code (verified against the working tree).
---
## CRITICAL
_None._
---
## HIGH
### N1 — COM `entity_create_hatch` builds an ASSOCIATIVE hatch then deletes its boundary polyline
- **Severity:** HIGH
- **Location:** `backends/com_backend.py:659-673`
- **Description:** `mspace.AddHatch(0, pattern, True)` requests an *associative* hatch linked to the appended boundary loop, but the code then calls `bnd_pline.Delete()` (`com_backend.py:673`) on that very polyline. In live AutoCAD this either errors on `Delete`, drops the boundary, or leaves a hatch whose boundary tracking breaks on the next regen/edit.
- **Fix:** Change the 3rd arg to non-associative — `AddHatch(0, pattern, False)` — so the computed fill survives deleting the throwaway boundary. If associativity is truly wanted, keep the boundary polyline instead of deleting it.
### N2 — `drawing_save_as` default format diverges (ezdxf `"dxf"` vs COM/ABC `"dwg"`); `drawing_finalize` relies on the default
- **Severity:** HIGH (merges register **R10**/**R11** with the fresh-sweep duplicate)
- **Location:** ABC default `backends/base.py:158` (`"dwg"`); ezdxf `backends/ezdxf_backend.py:346` (`"dxf"`); COM `backends/com_backend.py:516` (`"dwg"`); consumed with no fmt at `server.py:2299` (`drawing_finalize` → `backend.drawing_save_as(str(validated))`).
- **Description:** `drawing_finalize` passes no `fmt`, so each backend's default decides the output. Two distinct real defects layer here:
1. **ezdxf silently mislabels DWG (R11):** `drawing_save_as` *ignores* `fmt` entirely — `doc.saveas(path)` derives format from the extension — yet still returns `{"format": fmt}`. For `fmt="dwg"`/`"dwt"` it writes DXF tag-stream bytes (ezdxf has no DWG/DWT writer) while echoing `format:"dwg"`. (`ezdxf_backend.py:346-353`)
2. **COM writes DWG content under a `.dxf` name (R10):** COM's `SaveAs(path, fmt_map[fmt])` honors `fmt`, *not* the extension, so a `.dxf` save_path under COM yields DWG bytes in a `.dxf`-named file.
- Net: identical `drawing_finalize(save_path=...)` produces different on-disk formats per backend, decoupled from the extension the user supplied.
- **Fix:** In `drawing_finalize`, derive `fmt` from the `save_path` extension and pass it explicitly. Unify the ABC + both impl signatures to one default. In ezdxf, reject `fmt not in {dxf, dwt}` (raise `ToolError` for `dwg`) instead of writing DXF and echoing `format=fmt`.
---
## MED
### R4 — `dim_overlap` critique focus is a guaranteed silent no-op (no backend populates the keys it reads)
- **Severity:** MED (claimed HIGH; downgraded — silently returns `[]`, no incorrect output)
- **Location:** reads `engineering/critique.py:199-201`; extractors `backends/ezdxf_backend.py:167-169` and `backends/com_backend.py:204-244` (verified at `critique.py:199-201`).
- **Description:** `_check_dim_overlap` reads `text_position`/`defpoint`/`insertion_point`, but the ezdxf DIM branch sets only `props["dim_type"]` and COM dims get only a generic `bounding_box`. None of the three keys is produced anywhere, so `ip` is always falsy, `dim_points` stays empty, and the focus always returns `[]`.
- **Fix:** Populate a dim reference point (text midpoint / defpoint) in both `_entity_info` extractors for DIM types, OR fall back to `properties["bounding_box"]` center in `_check_dim_overlap`.
### R9 — ezdxf `entity_array_polar` overlaps the original on full-circle fill and diverges from COM
- **Severity:** MED
- **Location:** `backends/ezdxf_backend.py:947-948`
- **Description:** `step = radians(fill_angle) / max(count-1, 1)` with loop `range(1, count)` places the last copy at `step*(count-1) = 360°` for a full ring — coincident with the original at 0°. Also diverges from COM (`com_backend.py:921` uses AutoCAD `ArrayPolar`, spacing `360/count`): `count=4, fill=360` → COM 90/180/270 vs ezdxf 120/240/360. `count-1` is only correct for partial-arc fills.
- **Fix:** `divisor = count if abs(fill_angle % 360) < eps else max(count-1, 1)`.
### R12 — ezdxf `dimension_angular` ignores `tx/ty` and hardcodes `distance=10.0`
- **Severity:** MED
- **Location:** `backends/ezdxf_backend.py:706-723` (hardcode at `:715`)
- **Description:** Accepts `tx/ty` (advertised as "text position X/Y" at `server.py:721-722`) but calls `add_angular_dim_2l(..., distance=10.0)`, ignoring them. COM honors `tx/ty` via `AddDimAngular` (`com_backend.py:770-784`), so identical inputs place the arc differently per backend; the fixed 10.0 mislocates the arc for very large/small parts.
- **Fix:** Pass `location=(tx,ty)` to `add_angular_dim_2l`, or compute `distance=hypot(tx-vx, ty-vy)`.
### R13 / N2-offset — `entity_offset` ignores `side_x/side_y` in BOTH backends; side semantics diverge; ezdxf supports only LINE/CIRCLE
- **Severity:** MED (merges register **R13** with the two fresh-sweep offset findings)
- **Location:** ezdxf `backends/ezdxf_backend.py:869-909`; COM `backends/com_backend.py:861-875`; ABC sig `backends/base.py:315-319`; tool `server.py:859-868`.
- **Description:** Both backends accept `side_x/side_y` and never read them (dead params; the tool advertises them as "a point on the offset side"). ezdxf hard-fixes the LINE normal `Vec2(-dir.y, dir.x)*d` (`:882`) and CIRCLE `radius+d` (`:896`); every other type raises `RuntimeError("Offset not supported …")` (`:908`) — ARC/LWPOLYLINE/POLYLINE/ELLIPSE/SPLINE unsupported, contradicting the tool's "line, circle, or polyline" claim. COM delegates to `ent.Offset(distance)`, whose sign/side convention is not guaranteed to match ezdxf's fixed left-normal — same `(handle, distance)` can offset to opposite sides per backend.
- **Fix:** Honor `side_x/side_y` in both backends (pick the copy whose ref point is nearest the side point, or sign `distance` from the cross product against the offset direction). In ezdxf, support ARC/LWPOLYLINE/POLYLINE/ELLIPSE or drop the documented polyline/side claims. Reconcile so positive distance means the same side in both engines.
### R15 — `system_about` `tool_groups` static dict omits ~20 registered tools and misfiles one
- **Severity:** MED
- **Location:** `server.py:2070-2106`
- **Description:** The hand-maintained `tool_groups` dict omits `drawing_close`, `entity_trim/extend/fillet/chamfer`, all 7 engineering tools, and all 8 premium tools (~15+ shown), and lists `entity_delete_many` under `entity_creation` though it's tagged `{entity, modify}` with `destructiveHint=True`. Live `total_tools` stays correct; only the per-group breakdown drifts.
- **Fix:** Derive `tool_groups` dynamically from registered tools' tags, or at minimum add the engineering/premium groups, move `entity_delete_many`, and add the 5 missing modify/drawing tools.
### R18 — `drawing_new` bootstrap failures return as success
- **Severity:** MED
- **Location:** `server.py:282-293`; per-item swallow at `engineering/layers.py:90-91, 128-129`
- **Description:** Bootstrap is wrapped in `try/except` that stores `{"error": …}` and still `return result` representing a created drawing; per-item failures are caught internally as `"failed: {exc}"` strings. A fully- or partially-failed bootstrap looks like success, so geometry can land on missing engineering layers — a documented precondition violation.
- **Fix:** Surface `bootstrap_ok=False` / `status="degraded"` and/or raise `ToolError` when `bootstrap=True` and any linetype/layer reports `failed` or the catch-all fires.
### R19 — ezdxf `dimension_aligned` / `dimension_angular` ship broken (xfail strict=False masks fix/regression)
- **Severity:** MED
- **Location:** markers `tests/test_dimensions.py:16, 23`; root cause `backends/ezdxf_backend.py:696` (`dist=`) and `:711-715` (`center=/p1=/p2=/distance=`)
- **Description:** ezdxf 1.4.3 `add_aligned_dim()` is `(p1, p2, distance, …)` but code passes `dist=`; `add_angular_dim_2l()` is `(base, line1, line2, …)` but code passes `center=/p1=/p2=/distance=` → `TypeError`. Both are advertised MCP tools (`server.py:695, :714`) and recommended in prompt text (`:2639`). `xfail(strict=False)` means an accidental fix XPASSes silently.
- **Fix:** `dist→distance`; `base=(vx,vy)`, `line1=((vx,vy),(x1,y1))`, `line2=((vx,vy),(x2,y2))`. Change markers to `strict=True` (or remove) so the suite alerts.
### R22 — ezdxf transaction snapshots share the single `_undo_stack` with undo history; reload leaves `_current_layer` stale
- **Severity:** MED
- **Location:** `backends/ezdxf_backend.py:213` (single stack), `drawing_undo` `471-485`, `transaction_begin/commit/rollback` `1429-1477`, `system_status` `1489`
- **Description:** One `_undo_stack` is pushed/popped by both undo and transactions, with no txn id/marker/depth isolation; `system_status` reports `len(_undo_stack)` *as* `transaction_depth`. A `drawing_undo` silently consumes an open transaction's snapshot. On reload, undo/rollback re-read the doc but never reset `_current_layer`, so `is_current` reporting goes stale vs the snapshot's `$CLAYER`.
- **Fix:** Use a dedicated transaction list (or begin-markers/ids) distinct from undo history; on reload re-read `$CLAYER` into `self._current_layer`.
### R5 — COM thread/apartment leak on `SendCommand` timeout (no `CoUninitialize`)
- **Severity:** MED
- **Location:** `backends/com_backend.py:416-434` (timeout path), `69-72` (`_com_init`), `387-391` (disconnect)
- **Description:** `_com_init` calls `CoInitialize()` as the executor initializer; `CoUninitialize` appears nowhere in source. On `TimeoutError`, `stuck.shutdown(wait=False)` cannot cancel a worker still blocked in `SendCommand` (the inline comment admits "Abandon it"), and a fresh executor is built — each timeout permanently leaks one `CoInitialize`'d thread + the COM apartment it pinned. `disconnect()` has the same pattern.
- **Fix:** Bound the leak (track abandoned executors, avoid unbounded re-creation); add per-thread `CoUninitialize` via worker-side `try/finally`/thread-local teardown so apartments release when a timed-out thread eventually unblocks.
### R6 — COM backend (1651 lines, 85 async methods) has zero test coverage
- **Severity:** MED
- **Location:** `tests/conftest.py:5` (only `EzdxfBackend` imported); untested logic incl. CMDACTIVE guard `com_backend.py:1474`, transactions `1397-1419`
- **Description:** No test references `ComBackend`/`win32`/`SendCommand`/`CMDACTIVE`. The `backend` fixture instantiates only ezdxf. SendCommand-driven commands, the CMDACTIVE busy-guard + poll loop, and transaction begin/commit/rollback are unexercised, so COM logic can regress silently on non-Windows CI.
- **Fix:** Add `tests/test_com_backend.py` unit-testing the mockable logic (CMDACTIVE guard, `normalize_lineweight` usage, `_safe_send_command` polling, value coercion) with a mocked COM `app`/`doc`, gated by `@pytest.mark.skipif` when `win32com` is unavailable.
### N3 — `entity_select_smart` `length_range` always rejects ARCs (no backend populates ARC `"length"`)
- **Severity:** MED
- **Location:** `backends/base.py:897-904`
- **Description:** The gate fires for `("LINE","ARC")` and reads `e.properties.get("length")`, but the ARC extractor branches (`ezdxf_backend.py:135-139`, `com_backend.py:217-222`) set only center/radius/angles — never `length`. So every ARC has `length is None → return False`, silently excluding all arcs despite the advertised ARC support.
- **Fix:** Populate `props["length"]` for ARC (= `radius * |Δangle|` in radians, with wraparound) in both extractors, OR compute arc length on the fly in `entity_select_smart`, OR drop "ARC" from the gate and docstring.
### N4 — `dimension_auto` hardcodes `layer="DIM"`, which does not exist in the `iso13567` layer set
- **Severity:** MED
- **Location:** `backends/base.py:822, 836, 845, 852`
- **Description:** Every `dimension_linear` inside `dimension_auto` passes `layer="DIM"`, but the iso13567 set's dim layer is `"M-DIMEN-T-N"` (`engineering/layers.py:57`). After `drawing_apply_iso_layers("iso13567")`, dims land on an auto-created `"DIM"` (default color 7 / lw -3), bypassing the ISO layer. `_check_layer_color` won't flag it because `_EXPECTED_LAYER_COLOR` is built only from `ENGINEERING_LAYERS`. (The hardcoded `"CONSTRUCTION"` in `construction_xline/clear`, `base.py:743/:754`, has the same iso13567 mismatch — its construction layer is `"M-CONST-E-N"`.)
- **Fix:** Resolve the dimension (and construction) layer from the active layer set via a registry lookup keyed on `PlanSpec.layer_set_id` (mech→DIM, pid→DIM, iso13567→M-DIMEN-T-N) instead of literals.
### N5 — `EntityInfo.properties` shape diverges by backend (`bounding_box`, TEXT `rotation`, MTEXT `char_height`)
- **Severity:** MED (merges three fresh-sweep property-parity findings)
- **Location:** `bounding_box`: `com_backend.py:192-199` (every entity) vs `ezdxf_backend.py:122` (never); TEXT/MTEXT: `ezdxf_backend.py:144-152` vs `com_backend.py:229-237`.
- **Description:** COM seeds `properties["bounding_box"]` for *every* entity; ezdxf never adds it. ezdxf TEXT sets `rotation` and MTEXT sets `char_height`; COM TEXT/MTEXT set neither (COM MTEXT sets no height at all). Any client reading these keys works on one backend and `KeyError`s on the other.
- **Fix:** Pick a contract and enforce it both ways: add `bounding_box` to ezdxf via `ezdxf.bbox.extents` (or drop it from COM and expose extents only via `analysis_bounding_box`); add `rotation`/`char_height` to the COM TEXT/MTEXT branches (`rad2deg(entity.Rotation)`, `entity.Height`). Document the props contract.
### N6 — COM `system_run_lisp` always reports `result:"nil"` (SendCommand is void)
- **Severity:** MED (duplicates register **R23**'s value-capture half; R23's no-CMDACTIVE-guard half tracked at LOW below)
- **Location:** `backends/com_backend.py:1488-1493`
- **Description:** `result = doc.SendCommand("(progn …)")` then `str(result) if result else "nil"`. AutoCAD COM `SendCommand` is void-returning → `result` is always `None` → the tool unconditionally returns `"nil"` regardless of the actual LISP value.
- **Fix:** Drop the bogus `result` field, or capture out-of-band: `(setvar "USERS1" (vl-princ-to-string <expr>))` then `GetVariable("USERS1")` after completion.
---
## LOW
### R10/R11 residual — _covered under N2 (HIGH)._ No separate LOW entry; the format-default divergence is fully captured there.
### R16 — COM `transaction_commit/rollback` leave `_transaction_active` stale on error; rollback's `UNDO B` bypasses the CMDACTIVE guard
- **Severity:** LOW
- **Location:** `backends/com_backend.py:1406-1423` (commit `1406-1413`, rollback `1415-1423`; begin mirror at `1402-1403`)
- **Description:** `_transaction_active` is set/cleared *after* `await self._run(_sync)` with no `try/finally`; if `_run` raises, the flag stays stale. Rollback's `_sync` issues raw `doc.SendCommand("_UNDO B\n")` (`:1419`) without the CMDACTIVE pre-check/poll that `system_run_command`/`_safe_send_command` use — the documented deadlock hazard.
- **Fix:** Clear `_transaction_active` in a `finally` around `_run`; route rollback's `UNDO B` through `_safe_send_command`.
### R20 — `_registered_tool_count()` reaches through FastMCP private attrs; fails to `-1` and surfaces it
- **Severity:** LOW
- **Location:** `server.py:225-234` (private access at `:231-232`)
- **Description:** `mcp._local_provider._components` + the undocumented `"tool:"` key prefix, with `except Exception: return -1`. `-1` is surfaced unguarded as `tool_count` (`server.py:1969/1976/1983`) and `total_tools` (`:2107`). A minor `fastmcp>=3.0` restructuring silently degrades the count to `-1`.
- **Fix:** Use the public API (e.g. `len(await mcp.get_tools())`), making the helper async.
### R23 — COM `system_run_lisp` has no CMDACTIVE guard (deadlock on prompting LISP)
- **Severity:** LOW (value-capture half merged into N6)
- **Location:** `backends/com_backend.py:1488-1493`
- **Description:** Unlike `system_run_command` (guards at `1473-1482`) and `_safe_send_command` (polls at `1521-1537`), `system_run_lisp` sends raw with no CMDACTIVE pre-check/poll. A prompting form (`(command "_LINE")`, `(getpoint)`) leaves CMDACTIVE non-zero and deadlocks the next COM call.
- **Fix:** Route through `_safe_send_command` (or add the same pre-send guard + post-send poll).
### R24 — ezdxf `_apply_attrs` assigns linetype without `_ensure_linetype_loaded` → undefined names silently render Continuous
- **Severity:** LOW
- **Location:** `backends/ezdxf_backend.py:501-502`
- **Description:** `_apply_attrs` does `entity.dxf.linetype = linetype` with no load check, unlike `entity_set_properties:981`, `layer_create:1030`, `layer_modify:1075`. `entity_create_line` forwards a documented `linetype` param (`server.py:446`; a prompt even says "use entity_create_line with CENTER linetype"), but `_BUILTIN_LINETYPES` is only `{continuous, bylayer, byblock}`, so HIDDEN/CENTER fall through to Continuous.
- **Fix:** Call `_ensure_linetype_loaded(self._require_doc(), linetype)` before the assignment.
### R26 — ezdxf `view_zoom_window` is a no-op but reports success; `view_zoom_and_screenshot` ignores the window region
- **Severity:** LOW
- **Location:** `backends/ezdxf_backend.py:1395-1399` (zoom no-ops), `1401-1425` (screenshot ignores window); `server.py:1887-1893`
- **Description:** ezdxf zoom ops return `{"ok": True, "message": …}` while doing nothing. `view_zoom_and_screenshot` passes window coords to the no-op, then `view_screenshot` renders the entire modelspace via `draw_layout(msp)` with `bbox_inches="tight"`, ignoring `x1/y1/x2/y2` — so the image frames the whole drawing despite the docstring promising a window.
- **Fix:** Store a pending crop region in `view_zoom_window` and apply via `ax.set_xlim/set_ylim` (drop `bbox_inches="tight"` when cropping); or return `ok:False` for ezdxf window zoom so success no longer implies framing.
### R28 — COM screenshot HWND via title-substring `EnumWindows` instead of `AcadApplication.HWND`
- **Severity:** LOW
- **Location:** `backends/com_backend.py:284-301` (`:292`)
- **Description:** `_find_autocad_hwnd` picks the first visible top-level window whose caption contains `"AutoCAD"` — a palette, About dialog, or Start tab can win (EnumWindows ~ top-Z first). The COM app object's `.HWND` (the real main frame) is unused.
- **Fix:** Use `int(_acad_app().HWND)`; keep EnumWindows only as a fallback.
### R21 — Gear flank/root junction is a straight radial segment, not a fillet; radial discontinuity when `root_r >= base_r`
- **Severity:** LOW
- **Location:** `engineering/gear.py:40-50` (flank), `163-190` (root-arc stitch)
- **Description:** `generate_involute_flank` prepends `(root_r, 0.0)` while the involute starts at `(base_r, 0.0)` — both on y=0, so root→base is a straight radial segment, not a trochoid. When `root_r >= base_r` (z≥42 @20°, z≥27 @25°, all within the teeth≥6 API limit) no root point is added but the root arc is drawn at `root_r`, giving a radial jump in the closed polyline (verified m=2,z=60: 1.118 mm). (The teeth≤2 degenerate case is unreachable — tools enforce teeth≥6.)
- **Fix:** Anchor the flank/root junction at `root_r` (radially extend the first involute point, or use a true trochoid below the base circle); skip the root arc when `root_r >= base_r`.
### N7 — `point_from_snap` reads ARC angles via bracket indexing → opaque `KeyError` instead of the function's standard `RuntimeError`
- **Severity:** LOW
- **Location:** `backends/base.py:629-630, 646-647`
- **Description:** ARC `"end"`/`"mid"` branches use `p["start_angle"]`/`p["end_angle"]` direct subscripting, while every other geometry access uses `p.get(...)` + descriptive `RuntimeError`. If a backend's extraction failed (broad try/except swallows at `ezdxf:170`/`com:245`), this raises an opaque `KeyError` rather than the consistent error.
- **Fix:** Use `p.get(...)` with the same None-check `RuntimeError` pattern as `_line_pts`/`_circle_geom`.
### N8 — PlanSpec stored in `_plan_spec` is never read; `drawing_critique` does not "replay" it as documented
- **Severity:** LOW
- **Location:** write `backends/base.py:574`; never read in `engineering/critique.py` `run_critique` (`234-251`); docstrings `base.py:563`, `server.py:2351`
- **Description:** `drawing_plan` stores `self._plan_spec = plan`; docstrings claim it's "held by the backend and replayed by drawing_critique." But `run_critique` and all `_check_*` never reference `_plan_spec`/`.intent`/`.scale`/`.view_count`/`.dim_style` (grep: written once, read nowhere). The committed plan is dead state and the "replay" contract is unmet.
- **Fix:** Implement plan-vs-drawing checks that read `backend._plan_spec` (e.g. view_count vs detected views, dim_style vs dims present), or correct the docstrings to stop claiming replay.
### N9 — `view_zoom_extents`/`view_zoom_window` return different payload shapes (ezdxf adds `message`, COM omits it)
- **Severity:** LOW
- **Location:** `backends/ezdxf_backend.py:1395-1399` vs `backends/com_backend.py:1372-1385`
- **Description:** ezdxf returns `{"ok": True, "message": …}`; COM returns `{"ok": True}` with no `message`. Same MCP tool, two shapes — a client keying on `result["message"]` works on ezdxf and `KeyError`s/None on COM.
- **Fix:** Return identical keys from both backends (always include `message` + an `applied` bool).
---
## SMELL
### R17 — COM `_safe_send_command` polling holds the lone STA thread; inner `deadline_s` decoupled from outer `com_call_timeout`
- **Severity:** SMELL (downgraded from MED — functionally correct; inner 8s deadline is tighter than the 60s outer net, 50 ms sleep is a yielding wait, and holding the STA thread is unavoidable under STA)
- **Location:** `backends/com_backend.py:1498-1542`; `config.py:38`
- **Fix (optional):** Tie `deadline_s = min(8, com_call_timeout*0.8)` so the two stay consistent if the env var changes.
### R29 — Broad `except Exception` guards around optional COM reads
- **Severity:** SMELL (downgraded — adjacent unguarded calls re-raise genuine disconnects into `_run`; most sites log rather than silently pass; reconnect is not bypassed)
- **Location:** `backends/com_backend.py:200, 245, 251, 470, 1447, 1512-1548` (~30 sites)
- **Fix:** Narrow to `except (pywintypes.com_error, AttributeError)` so genuine logic bugs surface.
### S5 — Two parallel, conflicting layer systems
- **Severity:** SMELL
- **Location:** `server.py:1647-1688` (`_LAYER_TEMPLATES`) vs `engineering/layers.py:14-69` (`ENGINEERING_LAYERS`/`LAYER_SET_REGISTRY`)
- **Description:** `_LAYER_TEMPLATES` (architectural/mechanical/…) uses hundredths-int lineweights, all-Continuous linetypes, no CONSTRUCTION layer; `engineering/layers.py` (mech/pid/iso13567) uses mm-floats, real HIDDEN/CENTER, ISO-128 lineweights, CONSTRUCTION. The overlapping mechanical/mech sets *conflict* (HIDDEN/CENTER colors swapped), and `_LAYER_TEMPLATES` violates the ISO-128 discipline `drawing_critique` enforces.
- **Fix:** Retire `_LAYER_TEMPLATES`/`template_apply_layers` in favor of `LAYER_SET_REGISTRY` (or map legacy keys onto it) for one source of truth.
### S3 — `BlockInfo.description` always empty
- **Severity:** SMELL
- **Location:** declared `backends/base.py:71`; never set in `ezdxf_backend.py:1196-1202` / `com_backend.py:1186-1192`
- **Fix:** Populate `description=blk.block.dxf.get("description", "")` (ezdxf) + COM equivalent, or drop the field.
### S1 — `engineering/section.py` helpers (`cutting_plane_line`, `apply_section_hatch`) are dead
- **Severity:** SMELL
- **Location:** `engineering/section.py:12, 65`; only re-exported (`__init__.py:35, 65-66`), never called (`gear_draw_section_aa` builds its hatch inline).
- **Fix:** Wire a `section_*` MCP tool around them, or delete the module + exports.
### S2 — `generate_tooth_profile` is dead (superseded by `generate_full_gear_outline`)
- **Severity:** SMELL
- **Location:** `engineering/gear.py:74`; exported `__init__.py:9, 59`; duplicates ~80% of the outline math (unused local `t_pitch` at `:94`).
- **Fix:** Delete and remove import/`__all__` entries, or wire into a draw path.
### S4 — Stale "Agent B still in flight" build-seam comment + now-vestigial import guards
- **Severity:** SMELL
- **Location:** `engineering/__init__.py:37-39` (comment), `40-51` (try/except guards)
- **Description:** Both `titleblock.py` and `validator.py` now exist and import cleanly, so the guard's rationale is stale. (Related: **R14** — when a guarded symbol *is* None the tools call it and raise an opaque `TypeError`; currently unreachable since modules exist.)
- **Fix:** Delete the build-seam comment; either drop the guards (import directly) or have the tools raise `ToolError("engineering module unavailable: …")` when a symbol is None instead of calling it.
### N10 — `CommandResult` dataclass + `to_dict` never instantiated
- **Severity:** SMELL
- **Location:** `backends/base.py:26-38`; exported `backends/__init__.py:2, 8`
- **Description:** Defined and re-exported, but no constructor call exists outside docs; every command return uses a plain dict. CLAUDE.md/README still list it as a shared dataclass.
- **Fix:** Delete it + its export + doc refs, or actually adopt it as the command return type.
### N11 — `_ACI_NAMES` color-name table defined but never referenced
- **Severity:** SMELL
- **Location:** `backends/ezdxf_backend.py:54-57`
- **Fix:** Delete, or wire into `_entity_info_dxf`/`_layer_info_dxf` as a `color_name` field.
### N12 — `AutoCADBackend.set_layer_active` alias has zero callers
- **Severity:** SMELL
- **Location:** `backends/base.py:910-912`
- **Fix:** Remove; callers use `layer_set_current` directly.
### N13 — `AutoCADBackend.ensure_linetypes` concrete helper is dead (superseded by `engineering.layers.ensure_standard_linetypes`)
- **Severity:** SMELL
- **Location:** `backends/base.py:914-934`
- **Description:** Concrete ABC method with no callers; the real bootstrap path (`server.py:288`, `layers.py:110`) calls the module-level `ensure_standard_linetypes`, which reimplements the same already_loaded/loaded/failed loop.
- **Fix:** Delete it, or refactor `ensure_standard_linetypes` to delegate to it (single owner).
### N14 — COM `self._batch_mode` instance attr written but never read (`_regen` consults only `_COM_STATE`)
- **Severity:** SMELL
- **Location:** `backends/com_backend.py:363, 889, 907, 916, 933` vs `_regen` `180-187`
- **Description:** `_regen` guards on `_COM_STATE.get("batch_mode")` only. The array methods set *both* `self._batch_mode` and `_COM_STATE['batch_mode']`; the instance attr has no readers and can desync.
- **Fix:** Remove the redundant `self._batch_mode` assignments (keep the dict flag), or make `_regen` read the instance attr and drop the dict — one source of truth.
### R27 — Validator step 5 uses substring matching on all TEXT-layer text
- **Severity:** SMELL (downgraded from LOW — gated by `if helix:`, only runs when caller declares `helix_angle`; flagging a SPUR title on a declared-helical part is intended; only the over-broad substring scan is a smell)
- **Location:** `engineering/validator.py:159-181` (`:168`, `:175`)
- **Fix:** Restrict the scan to title-block text and use word-boundary regex (`\bSPUR\b`/`\bHELICAL\b`) instead of `in`.
### R25-residual — `block_find_references` INSERT cap
- **Severity:** SMELL (memory-bug claim REFUTED — `entity_list` defaults to `limit=200`, hard-capped; see appendix)
- **Location:** `server.py:1368-1369`
- **Fix (optional):** Warn when INSERT count hits the 200 cap to avoid silently under-reporting references in large drawings.
---
## (a) Count Summary by Severity
| Severity | Count | IDs |
|---|---|---|
| CRITICAL | 0 | — |
| HIGH | 2 | N1, N2 |
| MED | 13 | R4, R5, R6, R9, R12, R13, R15, R18, R19, R22, N3, N4, N5, N6 *(14 listed — see note)* |
| LOW | 9 | R16, R20, R23, R24, R26, R28, R21, N7, N8, N9 *(10 listed — see note)* |
| SMELL | 13 | R17, R29, R25, R27, S1, S2, S3, S4, S5, N10, N11, N12, N13, N14 *(14 — see note)* |
> Note: counts reflect merges (R10+R11 → N2; R23 split across N6+R23; R13 absorbs two fresh-sweep offset findings; R10 residual folded into N2). Exact kept-finding totals: **HIGH 2, MED 14, LOW 10, SMELL 14 = 40 kept**; 3 dropped (refuted/already-fixed appendix below).
---
## (b) Confirmed P1 Shortlist — highest-leverage fixes to tackle next
1. **N1 — COM hatch associativity/delete bug** (`com_backend.py:659-673`). Flip `AddHatch(…, True)` → `False`. One-line fix, prevents corrupt/invalid hatches in live AutoCAD.
2. **N2 — unify `drawing_save_as` format + derive from extension in `drawing_finalize`** (`base.py:158`, `ezdxf_backend.py:346`, `com_backend.py:516`, `server.py:2299`). Fixes silent DXF-as-DWG mislabeling and cross-backend format divergence on the primary output path.
3. **R19 — fix the two ezdxf dimension calls + make xfail strict** (`ezdxf_backend.py:696, 711-715`; `tests/test_dimensions.py:16,23`). Two advertised tools ship broken; cheap, high-visibility.
4. **R4 — make `dim_overlap` actually run** (`critique.py:199-201` + extractors). Restores a premium-workflow safety check that is currently a guaranteed no-op.
5. **R18 — surface bootstrap failure in `drawing_new`** (`server.py:282-293`). Prevents silent production-precondition violations (geometry on missing layers).
6. **N4 — parameterize `dimension_auto` / construction layers by active layer set** (`base.py:822/836/845/852, 743, 754`). Fixes ISO-13567 dims/construction landing on wrong auto-created layers.
7. **R9 — fix full-circle polar array overlap + COM divergence** (`ezdxf_backend.py:947`). Eliminates duplicate-entity output and a backend behavior split.
8. **R6 — add a mocked COM unit-test suite** (`tests/test_com_backend.py`). Unlocks regression safety for 1651 lines / 85 methods that currently can't be caught on CI — force-multiplier for every other COM fix above.
9. **R5 — bound the COM timeout thread/apartment leak** (`com_backend.py:416-434`). Prevents long-session resource exhaustion in live AutoCAD.
10. **R13/N5 — `entity_offset` side params + property-shape parity** (`ezdxf_backend.py:869-909`, `com_backend.py:861-875`; props extractors). Removes two dead documented params and the cross-backend `properties` contract drift that breaks downstream clients.
---
## (c) Refuted / Already-Fixed Appendix (checked, not actioned)
- **R1 — premium tools missing on COM:** ALREADY FIXED in `cd6329a`. The 8 premium meta-tools are now concrete methods on `AutoCADBackend` (`backends/base.py`); only `_create_xline` is backend-specific.
- **R2 — `int(lineweight)` truncation:** ALREADY FIXED in `cd6329a` via `normalize_lineweight()` in `base.py` (reconciles mm-float vs hundredths-int).
- **R3 — `iso128` critique no-op:** ALREADY FIXED in `cd6329a`.
- **R8 — _(no verification payload supplied; not assessed)._** Listed only for register completeness.
- **R14 — engineering import-guard `TypeError`:** PARTIAL / **not reachable**. Both `titleblock.py` and `validator.py` exist and import cleanly, so the `None`-fallback branch is never taken in the shipped repo. The vestigial guards/comment are carried as **S4** (SMELL).
- **R25 — `block_find_references` unbounded INSERTs + KeyError:** REFUTED. `entity_list` defaults to `limit=200` (hard-capped at all three layers), the opposite of "unbounded"; `e.properties.get("block_name")` cannot `KeyError`. Optional cap-warning carried as a SMELL above.
- **R29 — COM broad `except Exception` masks disconnects:** REFUTED as stated (reconnect not bypassed; most sites log). Retained as SMELL (narrow the guard types).
- **R17 — `_safe_send_command` deadline decoupling as a defect:** the defect framing is REFUTED (inner deadline is the tighter, primary one); retained as SMELL.
- **S4 (`Task #7/#8` phrasing):** the specific cited phrase is REFUTED (the comment says "Agent B … still in flight," not "Task #7/#8"); the substantive build-seam-comment smell STANDS and is kept as S4.
+146
View File
@@ -0,0 +1,146 @@
<!-- Generated by the IDEAS/roadmap workflow (wf_02a3262e-ddd), 2026-06-18. 19 ideas scored, 9 stress-tested. Pairs with docs/analysis/01-DIAGNOSTICS.md. -->
# AutoCAD MCP Pro — Evidence-Based Roadmap
> Mission: become the best AutoCAD MCP for LLM-driven drawing. The durable moat is the **standards-validation gate** (no competing CAD MCP — build123d / FreeCAD / Blender / Rhino / Onshape — ships any design-rule validation). Everything below is sequenced to widen that moat or to remove the silent no-ops that make it lie.
Priority signal = `(impact + differentiation + evidence_strength) / effort`, computed on **post-challenge adjusted** scores, then adjusted by judgment (quick wins and dependency order). DEFER → Backlog; RESCOPE → adjusted scope used; KEEP → as scored.
---
## Scoring table (all ideas, adjusted, sorted by priority signal)
| ID | Title | Imp | Eff | Diff | Ev | Signal | Verdict | Area |
|----|-------|-----|-----|------|----|--------|---------|------|
| **I9** | Expand deterministic geometry surface | 3 | 2 | 3 | 5 | **5.5** | KEEP→rescoped* | determinism |
| **I3** | ProCAD pre-plan clarification pass | 3 | 2 | 4 | 4 | **5.5** | RESCOPE | closed-loop |
| **I16** | Correctness + hygiene bundle (DIAGNOSTICS) | 3 | 2 | 2 | 5 | **5.0** | KEEP | hygiene |
| **I6** | GD&T primitive (ISO 1101 / Y14.5) | 5 | 3 | 5 | 3 | **4.3** | KEEP (impact↑) | iso-production |
| **I14** | NL→primitive grounding (trim/fillet/chamfer) | 3 | 3 | 3 | 4 | **3.3** | RESCOPE | determinism |
| **I15** | Staged validator (code→geometric→rubric) | 5 | 3 | 5 | 5 | **5.0** | RESCOPE | metrics |
| **I12** | Authenticated remote HTTP | 2 | 1 | 2 | 2 | **6.0** | KEEP (quick win) | infra |
| **I4** | Scalar drawing-score / Invalidity-Ratio | 3 | 2 | 3 | 4 | **5.0** | KEEP | metrics |
| **I2** | Visual critique / render as ImageContent | 3 | 2 | 4 | 5 | **6.0** | RESCOPE (cheap half) | closed-loop |
| **I18** | Real drawing repair (recover + Auditor.fix) | 2 | 2 | 1 | 3 | **3.0** | KEEP | robustness |
| **I1** | Routed iterative Refiner loop | 4 | 4 | 5 | 5 | **3.5** | RESCOPE | closed-loop |
| **I5** | ISO 129 tolerances on dimensions | 4 | 4 | 3 | 5 | **3.0** | RESCOPE | iso-production |
| **I8** | ISO-129 DimStyle + MLEADER | 3 | 3 | 2 | 3 | **2.7** | as scored | iso-production |
| **I19** | COM test harness (mocked) + cov gate | 2 | 2 | 1 | 3 | **3.0** | KEEP | hygiene |
| **I7** | Real plotted sheets + real DWG | 3 | 3 | 3 | 3 | **3.0** | RESCOPE (cheap half) | iso-production |
| **I17** | Fast selection via native query | 2 | 3 | 1 | 2 | **1.7** | ezdxf half only | perf |
| **I13** | Standard-parts / symbol library | 3 | 4 | 3 | 2 | **2.0** | RESCOPE | breadth |
| **I11** | Parametric editability / conv. edit | 3 | 4 | 3 | 2 | **2.0** | as scored | breadth |
| **I10** | 3D solids tier (or fix false claim) | 2 | 4 | 2 | 2 | **1.5** | split | breadth |
\* I9/I3 share the top signal; I9 edges first because it is zero-risk and directly converts the GeoGramBench failure mode (<50% program-to-geometry) into deterministic tool calls.
---
## Sprint 1 — Quick wins & correctness
Foundation: the closed-loop moat only pays off if the validator/critique signals are not silent no-ops. Land the cheap, CI-verifiable correctness fixes and the highest-signal determinism wins first.
### I16 — Correctness + hygiene bundle (DIAGNOSTICS fixes) · imp 3 / eff 2 / diff 2
**Why now:** Every premium-workflow signal that the moat depends on is currently degraded by confirmed defects (fake DWG, broken aligned/angular dims, polar-array dup, silent bootstrap). Iteration evidence (Self-Improving CAD +13.4pp/round) only pays off if signals aren't dead. **Depends on:** land alongside I19 (COM-only N1/R5 need a harness).
**First step:** One PR for the CI-verifiable wins: fix R19 (`ezdxf_backend.py:696` `dist`→positional `distance`; `:711` `add_angular_dim_2l(base=…, line1=…, line2=…, location=(tx,ty))`), flip `tests/test_dimensions.py:16,23` to `xfail(strict=True)`, fix R9 divisor (`:947`), R12 `location=(tx,ty)` (`:715`), R13 offset side via cross-product sign; then `ruff check --fix .` (41 auto) + 6 manual. Verified: ezdxf `drawing_save_as` ignores `fmt` and returns `{"format": fmt}` while writing DXF bytes (`ezdxf_backend.py:346-353`) — make it derive fmt from extension and refuse to mislabel.
### I9 — Expand deterministic geometry surface · imp 3 / eff 2 / diff 3
**Why now:** Highest priority signal. `point_from_snap` is a **concrete** base method sourcing from the shared `EntityInfo.properties` contract (verified at `base.py:585`), so new snaps are pure math in one place, zero per-backend code, free on COM+ezdxf. Finishes the pre-committed `int` stub (`plan_spec.py:24`). **Depends on:** none.
**First step:** Implement the deferred `point_intersection` (line-line / line-circle / circle-circle, ref-point disambiguation) and `point_tangent` (external-point-to-circle — highest leverage). Demote/drop `point_offset` (trivial vector add). Either cover LWPOLYLINE or document the gap explicitly. Dual-backend tests in `tests/test_meta_tools.py`.
### I12 — Authenticated remote HTTP · imp 2 / eff 1 / diff 2 — **QUICK WIN / latent security bug**
**Why now:** Confirmed: no `auth=` arg and no `TokenVerifier` anywhere (grep returned nothing). `_validate_http_bind` promises bearer-token auth but it is **never enforced** — a remote operator who sets `ALLOW_REMOTE_HTTP`+`MCP_AUTH_TOKEN` gets an unauthenticated server that believes it is authenticated. FastMCP 3.0.1 ships `StaticTokenVerifier`. ~10 lines.
**First step:** Build `_auth = StaticTokenVerifier(tokens={settings.mcp_auth_token: {...}}) if settings.mcp_auth_token else None`, pass `auth=_auth` into `FastMCP(...)` at `server.py:153`; add `tests/test_http_auth.py` (401 on bad/missing token, pass on correct).
### I12-adjacent quick win — I2 cheap half (render as ImageContent) · imp 3 / eff ~1 / diff 4
**Why now:** `fastmcp Image` is already imported/returned by `view_screenshot` (`server.py:1846+`); `drawing_finalize` writes screenshot bytes to disk and discards them. Returning the existing PNG bytes as `ImageContent` lets the already-multimodal driving agent (Claude) see the render — captures the CADSmith "render is a first-class signal" win with **no model bundling, no torch, no [vision] extra**.
**First step:** Add `return_image`/`include_render` to `drawing_finalize` (or a thin `visual_inspect` tool) that wraps existing bytes in `Image`. Keep the deterministic critique gate pure — the visual signal is advisory, **not** a `CritiqueFocus` member. Defer the local-VLM judge and OCR dim round-trip.
---
## Phase A — Own the moat (closed-loop validation)
This is the differentiation core. Sequence: scalar metric → staged unified validator → clarification gate → refiner loop. The metric makes the loop measurable; the unified validator fixes a real disconnect; the refiner acts on it.
### I4 — Scalar drawing-score / Invalidity-Ratio · imp 3 / eff 2 / diff 3
**Why now:** No quantitative metric exists today; `drawing_critique` returns a list, `validator.run` returns severity counts only. MUSE/CadBench grade Invalidity-Ratio, not shape. A scalar is the objective function that makes the repair loop measurable and the smoke test regression-trackable. **Depends on:** sequence after I16 (so the score isn't averaged over dead checks like R4 dim_overlap).
**First step:** Add `engineering/scoring.py::score_findings(severity_counts)` → `score = 100 − (errors·Wₑ + warnings·Wᵥᵥ + info·Wᵢ)` clamped, plus `invalidity_ratio = errors/max(1, findings)`. Consume from both `validator.py` and `critique.py`; surface in `drawing_finalize` payload. Document weights as a convention, not ground truth.
### I15 — Staged validator (code→geometric→rubric) · imp 5 / eff 3 / diff 5 — RESCOPE
**Why now:** Strongest hidden find: `drawing_finalize` (`server.py:2311`) calls **only** `DrawingValidator.run()` and **never** `run_critique` — the 6 premium critique focuses are not part of the finalize gate. Unifying validator's 8 steps + critique's 6 focuses into one staged protocol fixes a real architectural disconnect, not just cosmetics. **Depends on:** the no-op critique fixes (R4/R12/R19 in I16) must land first or the staged scalar lies. Drop R27 (separate SMELL); defer the VLM rubric stage to I2's deferred half.
**First step:** Define `StagedReport` (stages: code/geometric) with `{rule_id, stage, severity, passed, handles, detail}` + the I4 scalar; refactor `DrawingValidator.run` to tag findings with stage and reuse `_FOCUS_DISPATCH` for the geometric stage; wire **both** into `drawing_finalize`. Assert the scalar in `scripts/smoke_premium.py`.
### I3 — ProCAD pre-plan clarification pass · imp 3 / eff 2 / diff 4 — RESCOPE
**Why now:** ProCAD's one clarification round cut invalidity 14.6%→0.9% — attacks "garbage in" before any geometry, which no downstream critique recovers. `drawing_plan` is a thin concrete method building a `PlanSpec` (`base.py:553`). **Depends on:** none, but deep material/tolerance NL-extraction should wait for the I5 tolerance model. **Rescope:** ship cheap robust checks only — PlanSpec has no structured tolerance/material fields, so deep NL-parsing balloons effort.
**First step:** `engineering/plan_spec.py::detect_ambiguities(plan)` mirroring `_FOCUS_DISPATCH`: rule 1 = `view_count` vs intent keywords (section/orthographic/3-view); rule 2 = scale-vs-sheet sanity; rule 3 = ordinate dim_style needs a datum note. Surface advisory `clarifications` in the server wrapper return.
### I1 — Routed iterative Refiner loop · imp 4 / eff 4 / diff 5 — RESCOPE
**Why now:** The validation-gate-as-repair-loop is the moat no competitor has, directly operationalizing CADSmith Refiner (38x) and FEA-agent (+13.4pp/round). All repair primitives exist on both backends with transaction rollback. **Depends on:** I15 (unified staged report) and I16 (R4/R19). **Rescope to honest v1:** auto-repair only the 3 clean deterministic focuses + iso128-snap; route `untrimmed_corner` and `dim_overlap` to the LLM as **hints**, not auto-fixes.
**First step:** `engineering/refine.py` with a `REPAIR_HINTS` registry mapping each `CritiqueFocus` to (a) a deterministic repair coroutine and (b) a fallback hint. Implement the 4 trivial focuses (`construction_left`→`construction_clear`, `duplicate_entities`→`entity_delete(handles[1])`, `layer_color`→`layer_modify(color=detail.expected)`, `iso128`→snap-to-nearest-ISO). Then `drawing_refine(max_rounds=4)` looping critique→repair→re-critique. **Critical blocker (R22):** the ezdxf `_undo_stack` is shared between user undo and transactions with no txn-id isolation (`system_status` even reports `len(_undo_stack)` as transaction_depth) — add a dedicated txn list/guard before the rollback cycle, or it corrupts undo state.
---
## Phase B — Production ISO parity
Make output actually manufacturable. ISO 129 tolerances are the single biggest functional gap; GD&T is the highest-differentiation breadth item.
### I6 — GD&T primitive (ISO 1101 / Y14.5) · imp 5 / eff 3 / diff 5 — KEEP (impact↑)
**Why now:** Maximal differentiation — 2D GD&T is absent from every surveyed text-to-CAD product and every competing MCP. Pure composition over existing primitives (the `titleblock_apply_iso_a3` pattern, `server.py:2248`) — no ABC/dual-backend additions. Impact raised to 5: bundle a thin tolerance-text option and one GD&T critique check (datum letter in an FCF must have a matching datum feature) to convert it from "draws a symbol" to "validates a standard." **Depends on:** none (composes cleanly).
**First step:** `engineering/gdt.py` with the 14 ISO 1101 characteristic glyphs + Ⓜ/Ⓛ/Ⓢ modifiers (unicode/MTEXT codes), then `draw_feature_control_frame(...)` composing the frame from `entity_create_line` + `entity_create_text` on DIM layer. Register one `gd_frame` `@mcp.tool`. **Design constraint:** avoid ezdxf's native Tolerance/FCF entity (renders blank through matplotlib) — compose from lines+text. Smoke-test glyph rendering via screenshot.
### I5 — ISO 129 tolerances on dimensions · imp 4 / eff 4 / diff 3 — RESCOPE
**Why now:** A drawing without ±/limit/fit callouts is not manufacturable — the dossier's "biggest functional gap." Empirically verified ezdxf 1.4.3 renders sym/limit/text overrides via override dict. **Depends on:** R19 fix (I16) is a soft prerequisite — aligned/angular dims are currently broken, so don't bolt tolerances onto them yet. **Rescope:** ezdxf-first, CI-testable subset; defer aligned/angular tolerances behind R19; COM tolerance rendering best-effort/untested until I19.
**First step:** Add `tol_upper/tol_lower/tol_mode/fit/text_override` to `dimension_linear/radius/diameter` in `base.py`; implement in ezdxf via override dict (sym/limit → DIMTOL/DIMLIM + DIMTP/DIMTM; fit → `engineering/iso286_fits.py` nominal-band×grade table — the main authored-data cost). Test asserts the DIMENSION carries DIMTP/DIMTM and reads e.g. `20 +0.021/0`.
### I8 — ISO-129 DimStyle + MLEADER · imp 3 / eff 3 / diff 2 — as scored
**Why now:** Every dim today inherits the default "Standard" style (no DIMTXT/DIMASZ/precision). Natural sibling to `drawing_apply_iso_layers`. Low differentiation (table-stakes) — sequence after I5/I6. **Depends on:** I5 (share the dimstyle plumbing).
**First step:** `engineering/layers.py::apply_iso_dimstyle(backend)` → `doc.dimstyles.new("ISO-25", …)`; thread `dimstyle="ISO-25"` through ezdxf dim methods; wire into `drawing_apply_iso_layers`. Defer COM `DimStyles.Add` and the MLEADER tool to follow-ups.
### I7 — Cheap half: fmt-from-extension + true ISO-128 lineweights · imp 3 / eff 3 / diff 3 — RESCOPE
**Why now:** Two isolated wins. (1) Fake-DWG is ezdxf-only (COM already writes real DWG via `SaveAs(path,12)`); (2) ISO-128 true lineweights via ezdxf `Configuration` (`lineweight_policy`) on the render path is the genuinely differentiating piece and feeds the render/critique signal. **Defer** the from-scratch paperspace/viewport/Plot pipeline (multi-day, CI-unverifiable, `odafc` needs the ODA binary — confirmed not on PATH) to the backlog. **Depends on:** none.
**First step:** Wire `Configuration(lineweight_policy=…)` into the PDF/screenshot Frontend; unify the save_as fmt default and fail loud when ezdxf can't write real DWG (use `odafc.export_dwg` if present, else raise — never mislabel).
---
## Phase C — Breadth & determinism extras
Catch-up capability and convenience. Lower priority signals; do not displace the moat work.
### I14 — NL→primitive grounding for trim/fillet/chamfer · imp 3 / eff 3 / diff 3 — RESCOPE
**Why now:** Repair-side companion to the `untrimmed_corner` detector; attacks FutureCAD primitive-grounding + GeoGramBench. **Rescope:** "which corner" is net-new work — `analysis_bounding_box` returns only a global bbox, so corner→handle mapping re-runs O(n²) endpoint clustering. Ship ezdxf path with bbox-quadrant corner naming that **errors with candidates when ambiguous** instead of guessing; defer COM until I19 lands COM tests. **Depends on:** I9 (intersection math), I19 (COM verification).
### I18 — Real drawing repair (recover + Auditor.fix) · imp 2 / eff 2 / diff 1 — KEEP
**Why now:** Confirmed honesty bug: `drawing_audit` returns only `auditor.errors`, ignores `auditor.fixes`, while the docstring claims it "fixes errors"; `drawing_open` uses bare `readfile` with no `recover.readfile` fallback. Near-free robustness win.
**First step:** Return `errors`+`fixes`+`fixed_count` and `_mark_dirty()` when fixes applied; wrap `readfile` with a `recover.readfile` fallback.
### I19 — COM test harness (mocked) + scoped coverage gate · imp 2 / eff 2 / diff 1 — KEEP
**Why now:** R6 confirmed — zero tests touch `ComBackend`; CI is ubuntu-only so 1651 lines never run. `normalize_lineweight` (the P0 anti-truncation fix, pure/OS-agnostic) has no regression guard. Unblocks COM verification for I14/I5/I16. **Depends on:** none; should land **before/with** I16's COM-only items (N1/R5).
**First step:** `tests/test_com_backend.py` with a `sys.modules` fake-COM shim; parametrized `normalize_lineweight` table tests + `_safe_send_command` CMDACTIVE/timeout/var-restore tests. Scope `--cov-fail-under` to `backends.base` + tested modules, not repo-wide.
### Backlog / Later (!)
- **I17** — Fast selection: do the **ezdxf-only** half (`query()`/`groupby()` one-liners, zero-risk, headless-testable); defer the COM `SelectAll` rewrite (CI-unverifiable, finicky compound filters). Not a moat-widener.
- **I13** — Standard-parts library: ship the cheap `library_insert` wrapper + a few parametric ISO bolt block defs; the real cost (ISO-correct fastener + P&ID symbol authoring) is large ongoing work. Defer dynamic-block driving (COM-only, breaks dual-backend parity).
- **I11** — Parametric editability: ship single-entity `entity_edit_geometry` (set radius/endpoints) first; true re-drive needs a constraint graph the architecture has no scaffold for. Catch-up, not moat.
- **I10** — 3D solids: **do the near-zero-effort honesty fix now** (correct the false `"all_entity_types"` capability at `com_backend.py:1444`). Defer the COM-only solids tier; ezdxf has no ACIS kernel so it breaks dual-backend parity, and all cited research targets 2D fidelity.
- **I7 expensive half** — paperspace/viewport/Plot pipeline + bundled-VLM judge.
---
## Recommended next 5 work items
1. **I16 (Correctness bundle) + I19 (COM harness) together.** Highest evidence strength; everything in Phase A is built on signals that are currently silent no-ops (R4 dim_overlap, R19 broken dims, fake DWG). The COM harness lands with it so the COM-only fixes (N1, R5) and `normalize_lineweight` are actually guarded. Foundation for the entire moat.
2. **I9 (Deterministic geometry surface).** Top priority signal, zero risk, one-place pure math. Directly converts the GeoGramBench <50% program-to-geometry failure into deterministic tool calls — the cheapest moat-adjacent win available.
3. **I12 (HTTP auth) + I2-cheap (render as ImageContent).** Two quick wins. I12 closes a real false-security gap (~10 lines, verified no auth wiring exists). I2-cheap captures the CADSmith "render is a first-class signal" win for ~1 day with no model bundling, by returning the already-produced PNG to the multimodal driver.
4. **I4 → I15 (Scalar metric → unified staged validator).** I4 gives the objective function; I15 fixes the confirmed disconnect where `drawing_finalize` never runs the premium critique focuses. Together they make the moat measurable, regression-testable, and actually enforced at finalize — the prerequisite for the refiner.
5. **I6 (GD&T primitive).** Highest-differentiation feature in the dossier, KEEP at effort 3, impact raised to 5 by bundling a tolerance-text option and one datum-consistency critique. Pure composition, no backend surface change — the single biggest "no competitor has this" capability.
Sequencing rationale: items 1–4 are explicitly ordered by `depends_on` (signals must be honest before the scalar averages them; the scalar must exist before the refiner can optimize it; R22 txn-isolation gates the refiner's rollback cycle). Item 5 is parallelizable — it composes cleanly and shares no dependency with the closed-loop track, so it can run alongside Phase A.
---
## Security note — unverified DIAGNOSTICS items (R7/R8)
The diagnostics flagged two security items that the security-review subagents **could not verify**: **R7** (input-sanitizer wiring — whether `system_run_command` / `system_run_lisp` user strings are actually routed through a sanitizer before reaching `SendCommand`) and **R8** (absence of adversarial sanitizer tests). These sit on the live-COM command path, which is the highest-blast-radius surface in the repo (arbitrary AutoLISP/command execution) and is exactly the path with **zero COM test coverage** (R6).
**Recommendation — add a defensive hardening work item (slot in Sprint 1, alongside I19):** (a) trace `system_run_command`/`system_run_lisp` end-to-end and confirm every user-supplied string passes through an explicit allow/deny sanitizer before `_safe_send_command`; if no sanitizer is wired, add one (reject embedded newlines, `\x03`/ESC injection, nested `(command …)` escapes, and `SCRIPT`/`SHELL`/`START` command words). (b) Add adversarial unit tests on the mocked-COM harness from I19 asserting that known injection payloads are rejected before reaching `SendCommand`. This is cheap insurance on the one surface where a prompt-injected agent could execute arbitrary OS-level actions via `SHELL`/`START`, and it composes directly with the I19 fake-COM fixture.
File diff suppressed because one or more lines are too long
+2 -21
View File
@@ -6,7 +6,6 @@ from .gear import (
draw_spur_gear_front_view, draw_spur_gear_front_view,
generate_full_gear_outline, generate_full_gear_outline,
generate_involute_flank, generate_involute_flank,
generate_tooth_profile,
involute_xy, involute_xy,
) )
from .keyway import ( from .keyway import (
@@ -32,23 +31,8 @@ from .plan_spec import (
SheetSize, SheetSize,
SnapType, SnapType,
) )
from .section import apply_section_hatch, cutting_plane_line from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
from .validator import DrawingValidator, ValidationFinding, ValidationResult
# Agent B contributes titleblock + validator. Imports are guarded so this
# package remains usable while B is still in flight; once those modules land
# the symbols flow through transparently.
try:
from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
except ImportError:
TitleBlockMetadata = None # type: ignore[assignment]
apply_iso_a3_titleblock = None # type: ignore[assignment]
try:
from .validator import DrawingValidator, ValidationFinding, ValidationResult
except ImportError:
DrawingValidator = None # type: ignore[assignment]
ValidationFinding = None # type: ignore[assignment]
ValidationResult = None # type: ignore[assignment]
__all__ = [ __all__ = [
"draw_helical_gear_front_view", "draw_helical_gear_front_view",
@@ -56,14 +40,11 @@ __all__ = [
"draw_gear_section_aa", "draw_gear_section_aa",
"generate_full_gear_outline", "generate_full_gear_outline",
"generate_involute_flank", "generate_involute_flank",
"generate_tooth_profile",
"involute_xy", "involute_xy",
"draw_keyed_bore", "draw_keyed_bore",
"draw_keyway_section", "draw_keyway_section",
"keyway_dimensions", "keyway_dimensions",
"DIN6885_TABLE", "DIN6885_TABLE",
"cutting_plane_line",
"apply_section_hatch",
"ENGINEERING_LAYERS", "ENGINEERING_LAYERS",
"STANDARD_LINETYPES", "STANDARD_LINETYPES",
"ensure_engineering_layers", "ensure_engineering_layers",
+7 -7
View File
@@ -34,7 +34,7 @@ _DIM_TYPES = {
} }
async def _check_iso128(backend: "AutoCADBackend") -> list[Issue]: async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
"""Layer lineweights must be in ISO_128_LINEWEIGHTS_MM (or -3=ByLayer """Layer lineweights must be in ISO_128_LINEWEIGHTS_MM (or -3=ByLayer
sentinel meaning 'inherit').""" sentinel meaning 'inherit')."""
issues: list[Issue] = [] issues: list[Issue] = []
@@ -69,7 +69,7 @@ async def _check_iso128(backend: "AutoCADBackend") -> list[Issue]:
return issues return issues
async def _check_layer_color(backend: "AutoCADBackend") -> list[Issue]: async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
"""Engineering-standard layers must keep their canonical ACI color.""" """Engineering-standard layers must keep their canonical ACI color."""
issues: list[Issue] = [] issues: list[Issue] = []
try: try:
@@ -93,7 +93,7 @@ async def _check_layer_color(backend: "AutoCADBackend") -> list[Issue]:
return issues return issues
async def _check_construction_left(backend: "AutoCADBackend") -> list[Issue]: async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
"""CONSTRUCTION layer must be empty before drawing_finalize.""" """CONSTRUCTION layer must be empty before drawing_finalize."""
try: try:
ents = await backend.entity_list(layer_filter="CONSTRUCTION", limit=5000) ents = await backend.entity_list(layer_filter="CONSTRUCTION", limit=5000)
@@ -113,7 +113,7 @@ async def _check_construction_left(backend: "AutoCADBackend") -> list[Issue]:
)] )]
async def _check_untrimmed_corner(backend: "AutoCADBackend") -> list[Issue]: async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
"""Two LINE endpoints within `tol` but not exactly equal usually means a """Two LINE endpoints within `tol` but not exactly equal usually means a
corner where one line overshoots — TRIM/EXTEND/FILLET was forgotten.""" corner where one line overshoots — TRIM/EXTEND/FILLET was forgotten."""
tol = 0.5 # mm — generous; tighten via PlanSpec.notes later tol = 0.5 # mm — generous; tighten via PlanSpec.notes later
@@ -156,7 +156,7 @@ async def _check_untrimmed_corner(backend: "AutoCADBackend") -> list[Issue]:
return issues return issues
async def _check_duplicate_entities(backend: "AutoCADBackend") -> list[Issue]: async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
"""Two LINEs with identical endpoints (in either direction) are duplicates.""" """Two LINEs with identical endpoints (in either direction) are duplicates."""
try: try:
lines = await backend.entity_list(type_filter="LINE", limit=5000) lines = await backend.entity_list(type_filter="LINE", limit=5000)
@@ -185,7 +185,7 @@ async def _check_duplicate_entities(backend: "AutoCADBackend") -> list[Issue]:
return issues return issues
async def _check_dim_overlap(backend: "AutoCADBackend") -> list[Issue]: async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
"""Two dimensions whose insertion points are within `tol` likely overlap.""" """Two dimensions whose insertion points are within `tol` likely overlap."""
tol = 5.0 # mm tol = 5.0 # mm
try: try:
@@ -232,7 +232,7 @@ _FOCUS_DISPATCH = {
async def run_critique( async def run_critique(
backend: "AutoCADBackend", backend: AutoCADBackend,
focus: list[CritiqueFocus] | None = None, focus: list[CritiqueFocus] | None = None,
) -> list[Issue]: ) -> list[Issue]:
"""Dispatch each requested focus check and return aggregated issues.""" """Dispatch each requested focus check and return aggregated issues."""
+4 -56
View File
@@ -71,58 +71,6 @@ def _arc_points(
return pts return pts
def generate_tooth_profile(
module: float,
teeth: int,
pressure_angle: float = 20.0,
n_points_per_flank: int = 40,
) -> list[tuple[float, float]]:
"""Single tooth profile: right flank, tip arc, mirrored left flank."""
pa = math.radians(pressure_angle)
pitch_r = module * teeth / 2.0
base_r = pitch_r * math.cos(pa)
outer_r = pitch_r + module
root_r = pitch_r - 1.25 * module
# Tooth half-thickness angle on the pitch circle.
s = math.pi * module / 2.0
theta_pitch = s / pitch_r / 2.0
# Find involute parameter at pitch circle so we can shift the flank to be
# symmetric about the tooth centerline (y=0 axis after rotation).
if outer_r > base_r:
t_pitch = math.sqrt((pitch_r / base_r) ** 2 - 1.0) if pitch_r > base_r else 0.0
else:
t_pitch = 0.0
inv_pitch = math.tan(pa) - pa # involute function at pressure angle
# The flank generated by `generate_involute_flank` starts at theta=0 on the
# base circle. The involute point at the pitch radius makes angle inv_pitch
# (approx). We rotate the flank by -inv_pitch + theta_pitch so the pitch
# crossing sits at +theta_pitch above the tooth-center axis.
rot = -inv_pitch + theta_pitch
right_flank = generate_involute_flank(base_r, root_r, outer_r, n_points_per_flank)
right_flank = _rotate_points(right_flank, rot)
# Left flank by mirroring across the X axis (tooth centerline).
left_flank = [(x, -y) for x, y in right_flank]
# Tip arc connects the outermost right-flank point to the outermost left-flank point.
tip_right = right_flank[-1]
tip_left = left_flank[-1]
a_right = math.atan2(tip_right[1], tip_right[0])
a_left = math.atan2(tip_left[1], tip_left[0])
if a_left > a_right:
a_left -= 2 * math.pi
tip_arc = _arc_points(0.0, 0.0, outer_r, a_right, a_left, n=5)
profile: list[tuple[float, float]] = []
profile.extend(right_flank)
profile.extend(tip_arc[1:-1])
profile.extend(reversed(left_flank))
return profile
def generate_full_gear_outline( def generate_full_gear_outline(
module: float, module: float,
teeth: int, teeth: int,
@@ -200,7 +148,7 @@ def generate_full_gear_outline(
async def _draw_helix_symbol( async def _draw_helix_symbol(
backend: "AutoCADBackend", backend: AutoCADBackend,
*, *,
cx: float, cx: float,
cy: float, cy: float,
@@ -237,7 +185,7 @@ async def _draw_helix_symbol(
async def draw_helical_gear_front_view( async def draw_helical_gear_front_view(
backend: "AutoCADBackend", backend: AutoCADBackend,
*, *,
module: float, module: float,
teeth: int, teeth: int,
@@ -330,7 +278,7 @@ async def draw_helical_gear_front_view(
async def draw_spur_gear_front_view( async def draw_spur_gear_front_view(
backend: "AutoCADBackend", backend: AutoCADBackend,
*, *,
module: float, module: float,
teeth: int, teeth: int,
@@ -356,7 +304,7 @@ async def draw_spur_gear_front_view(
async def draw_gear_section_aa( async def draw_gear_section_aa(
backend: "AutoCADBackend", backend: AutoCADBackend,
*, *,
gear_metadata: dict, gear_metadata: dict,
x_offset: float, x_offset: float,
+2 -2
View File
@@ -56,7 +56,7 @@ def keyway_dimensions(bore_diameter: float) -> dict:
async def draw_keyed_bore( async def draw_keyed_bore(
backend: "AutoCADBackend", backend: AutoCADBackend,
*, *,
center: tuple[float, float], center: tuple[float, float],
bore_diameter: float, bore_diameter: float,
@@ -110,7 +110,7 @@ async def draw_keyed_bore(
async def draw_keyway_section( async def draw_keyway_section(
backend: "AutoCADBackend", backend: AutoCADBackend,
*, *,
center: tuple[float, float], center: tuple[float, float],
bore_diameter: float, bore_diameter: float,
+3 -3
View File
@@ -71,7 +71,7 @@ LAYER_SET_REGISTRY: dict[str, list[tuple[str, int, str, float, str]]] = {
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"] STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
async def ensure_standard_linetypes(backend: "AutoCADBackend") -> dict[str, str]: async def ensure_standard_linetypes(backend: AutoCADBackend) -> dict[str, str]:
"""Idempotently load STANDARD_LINETYPES via backend.linetype_load.""" """Idempotently load STANDARD_LINETYPES via backend.linetype_load."""
try: try:
existing = {ln.lower() for ln in await backend.linetype_list()} existing = {ln.lower() for ln in await backend.linetype_list()}
@@ -92,13 +92,13 @@ async def ensure_standard_linetypes(backend: "AutoCADBackend") -> dict[str, str]
return results return results
async def ensure_engineering_layers(backend: "AutoCADBackend") -> dict[str, str]: async def ensure_engineering_layers(backend: AutoCADBackend) -> dict[str, str]:
"""Idempotently create every layer in ENGINEERING_LAYERS via backend.layer_create.""" """Idempotently create every layer in ENGINEERING_LAYERS via backend.layer_create."""
return await apply_layer_set(backend, "mech") return await apply_layer_set(backend, "mech")
async def apply_layer_set( async def apply_layer_set(
backend: "AutoCADBackend", standard: str = "mech", backend: AutoCADBackend, standard: str = "mech",
) -> dict[str, str]: ) -> dict[str, str]:
"""Idempotently apply a named layer set ('mech', 'pid', 'iso13567').""" """Idempotently apply a named layer set ('mech', 'pid', 'iso13567')."""
layer_set = LAYER_SET_REGISTRY.get(standard) layer_set = LAYER_SET_REGISTRY.get(standard)
-82
View File
@@ -1,82 +0,0 @@
"""Generic cutting-plane and section-hatch helpers."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
async def cutting_plane_line(
backend: "AutoCADBackend",
*,
p1: tuple[float, float],
p2: tuple[float, float],
label: str = "A",
layer: str = "PHANTOM",
) -> dict:
"""Draw a section line, arrowheads at each end, and 'X-X' labels."""
x1, y1 = float(p1[0]), float(p1[1])
x2, y2 = float(p2[0]), float(p2[1])
line = await backend.entity_create_line(x1, y1, x2, y2, layer=layer)
dx, dy = x2 - x1, y2 - y1
length = math.hypot(dx, dy) or 1.0
ux, uy = dx / length, dy / length
nx, ny = -uy, ux
arrow_len = max(length * 0.05, 4.0)
arrow_w = arrow_len * 0.4
arrows: list[str] = []
for tip_x, tip_y, dir_x, dir_y in (
(x1, y1, -ux, -uy),
(x2, y2, ux, uy),
):
bx = tip_x + dir_x * arrow_len
by = tip_y + dir_y * arrow_len
wing1_x = bx + nx * arrow_w
wing1_y = by + ny * arrow_w
wing2_x = bx - nx * arrow_w
wing2_y = by - ny * arrow_w
a1 = await backend.entity_create_line(tip_x, tip_y, wing1_x, wing1_y, layer=layer)
a2 = await backend.entity_create_line(tip_x, tip_y, wing2_x, wing2_y, layer=layer)
arrows.extend([a1.handle, a2.handle])
text = f"{label}-{label}"
text_h = max(arrow_len, 3.5)
offset = arrow_len * 1.5
labels: list[str] = []
for tip_x, tip_y, dir_x, dir_y in (
(x1, y1, -ux, -uy),
(x2, y2, ux, uy),
):
tx = tip_x + dir_x * offset + nx * offset * 0.5
ty = tip_y + dir_y * offset + ny * offset * 0.5
t = await backend.entity_create_text(text, tx, ty, height=text_h, layer="TEXT")
labels.append(t.handle)
return {"line": line.handle, "arrows": arrows, "labels": labels}
async def apply_section_hatch(
backend: "AutoCADBackend",
*,
boundary_points: list[list[float]],
pattern: str = "ANSI31",
angle: float = 45.0,
scale: float = 1.0,
layer: str = "HATCH",
) -> str:
"""Wrapper around backend.entity_create_hatch returning the new handle."""
info = await backend.entity_create_hatch(
pattern=pattern,
boundary_points=boundary_points,
scale=scale,
angle=angle,
layer=layer,
)
return info.handle
+1 -1
View File
@@ -55,7 +55,7 @@ class TitleBlockMetadata:
async def apply_iso_a3_titleblock( async def apply_iso_a3_titleblock(
backend: "AutoCADBackend", backend: AutoCADBackend,
*, *,
metadata: TitleBlockMetadata, metadata: TitleBlockMetadata,
origin: tuple[float, float] = (0.0, 0.0), origin: tuple[float, float] = (0.0, 0.0),
+1 -1
View File
@@ -62,7 +62,7 @@ class DrawingValidator:
async def run( async def run(
self, self,
backend: "AutoCADBackend", backend: AutoCADBackend,
*, *,
expected: dict | None = None, expected: dict | None = None,
) -> ValidationResult: ) -> ValidationResult:
+223
View File
@@ -0,0 +1,223 @@
# AutoCAD MCP Pro — Oturum Notları
Tarih: 2026-06-18
Kullanıcı: Umutcan Edizsalan (umutcon12@gmail.com)
Hedef: Projeye tam hakim ol → bug/sorun tespit et → geliştirme yol haritası üret → onay sonrası uygula.
---
## YAPILDI ✅ — RECON Workflow (Task #1)
### Ne yapıldı
- 16 subagent çalıştırıldı: 8 kod-haritalama + 7 web araştırması + 1 sentez.
- Tüm kod tabanı `file:line` hassasiyetinde haritalandı.
- Web araştırması (arXiv, rakip MCP'ler, ezdxf/COM API, ISO standartları) tamamlandı.
- Sentez dosyası: `docs/analysis/00-RECON-dossier.md` (31KB, tam rapor)
- Ham veri: `docs/analysis/_recon_raw.json`
### Temel Bulgular
**Gerçek sayılar (dökümanda ne yazıyor vs gerçek):**
| Konu | Dökümanda | Gerçek |
|---|---|---|
| Tool sayısı | "87 tools" | **109 @mcp.tool** |
| Test sayısı | "104 tests" | **184 test fonksiyonu** |
| Section 1 | "(11 tools)" | **12 araç** (drawing_redo fazla) |
| Section 9 | "(5 tools)" | **4 araç** |
| LICENSE dosyası | "MIT — see LICENSE" | **YOK** (legal açık) |
| CI (.github/workflows/ci.yml) | "GitHub Actions CI" | **SİLİNMİŞ** |
| benchmarks/ klasörü | referans var | **YOK** |
| .claude/skills/ ve .cursor/ | referans var | **GIT'TEN SİLİNMİŞ** |
---
## YAPILDI ✅ — FAZ 1: P0 FIX (commit cd6329a, branch fix/p0-com-premium-lineweight)
- **R1 FİXLENDİ:** 8 premium meta-tool base sınıfına (AutoCADBackend) concrete + backend-agnostik olarak taşındı → COM artık miras alıyor, çökme bitti. Tek backend-özel parça yeni `_create_xline` primitifi. ezdxf'ten ~285 satır tekrar silindi.
- **R2 FİXLENDİ:** `normalize_lineweight()` eklendi (mm-float ↔ hundredths-int konvansiyon çakışması çözüldü); 6 çağrı sitesine bağlandı. ISO-128 lineweight'ler artık 0'a yuvarlanmıyor.
- **R3 FİXLENDİ:** iso128 kritiği artık ateşleniyor (R2 sayesinde); CONSTRUCTION katmanları muaf tutuldu (yanlış-pozitif engeli); yanıltıcı yorum düzeltildi.
- **LICENSE** eklendi (MIT). **ci.yml** geri yüklendi (lint geçici non-blocking; test job gerçek gate).
- **Doğrulama:** 182 passed / 2 xfailed + fonksiyonel test (GEOMETRY lw=50, kritik 0.40mm'yi yakalıyor).
---
## YAPILDI ✅ — FAZ 2: DIAGNOSTICS + IDEAS (workflow'lar)
- **DIAGNOSTICS** (`docs/analysis/01-DIAGNOSTICS.md`): 31 doğrulandı, 28 onaylandı, 3 reddedildi (R25/R29/R17 adversarial olarak çürütüldü), taramadan 17 yeni bulgu. → 0 CRITICAL (R1-R3 fixli), 2 HIGH (N1 COM hatch, N2 save-format), 14 MED, 10 LOW, 14 SMELL.
- **IDEAS/ROADMAP** (`docs/analysis/02-ROADMAP.md`): 19 fikir Impact×Effort×Diff×Evidence ile skorlandı, 9'u challenge edildi. Sprint 1 (correctness+quick wins) → Phase A (closed-loop moat) → Phase B (ISO production) → Phase C (breadth).
- **İki kritik yeni bulgu:** (1) HTTP auth hiç zorlanmıyor (latent güvenlik), (2) `drawing_finalize` `run_critique`'i HİÇ çağırmıyor — moat finalize'da devrede değil.
- **R7/R8 güvenlik:** otomatik doğrulanamadı (cyber-filter) → savunmacı hardening kalemi olarak Sprint 1'e eklendi.
**Önerilen sonraki 5:** ① I16 correctness bundle + I19 COM test harness · ② I9 deterministik geometri · ③ I12 HTTP auth + I2 render-as-image · ④ I4 scalar score + I15 birleşik validator · ⑤ I6 GD&T.
**Durum: Task #4 (konsolide rapor) HAZIR → kullanıcı onayı bekleniyor (FAZ 3 = uygulama).**
---
## KRİTİK BUGLAR (R1-R3 ARTIK FİXLİ — kalanlar FAZ 2 DIAGNOSTICS'te doğrulandı)
### R1 — KRİTİK: Premium araçlar COM'da çöküyor
- **Konum:** `backends/com_backend.py:1637-1660`
- **Sorun:** 8 premium meta-tool (`drawing_plan`, `drawing_critique`, `point_from_snap`, `construction_xline`, `construction_clear`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`) COM backend'inde `NotImplementedError` fırlatıyor.
- **Sonuç:** CLAUDE.md'nin "üretim için olmazsa olmaz" dediği akış, **Windows'taki varsayılan backend'de (COM) çöküyor**. Yani kullanıcı AutoCAD açıp kullandığında bu araçlar çalışmıyor.
### R2 — KRİTİK: Lineweight sıfırlanıyor
- **Konum:** `backends/ezdxf_backend.py:1034`
- **Sorun:** `int(lineweight)` → 0.50, 0.25, 0.18, 0.13 mm gibi tüm ISO-128 değerleri **0'a yuvarlanıyor**.
- **Sonuç:** Tüm ISO-128 çizgi kalınlığı disiplini sessizce sıfırlanıyor. `drawing_apply_iso_layers` ve `drawing_new` bootstrap'ı da etkileniyor.
### R3 — YÜKSEK: ISO-128 kritiği kalıcı no-op
- **Konum:** `engineering/critique.py:50`
- **Sorun:** `_check_iso128` içinde `lw <= 0: continue` → R2 yüzünden zaten hepsi 0 → kritik hiçbir zaman ateşlenmiyor.
---
## RİSK KAYITÇISI (Tüm Bulgular, Öncelik Sırasına Göre)
### KRİTİK (2 adet)
| # | Konu | Konum |
|---|---|---|
| R1 | 8 premium araç COM'da NotImplementedError | com_backend.py:1637-1660 |
| R2 | int(lineweight) → 0, tüm ISO-128 sıfırlandı | ezdxf_backend.py:1034 |
### YÜKSEK (8 adet)
| # | Konu | Konum |
|---|---|---|
| R3 | iso128 kritiği kalıcı no-op | critique.py:50 |
| R4 | dim_overlap kritiği da no-op (properties yok) | critique.py:194-197 |
| R5 | COM timeout → thread leak + CoUninitialize yok | com_backend.py:415-433 |
| R6 | COM backend'de SIFIR test | tests/ (ComBackend import yok) |
| R7 | Server→sanitizer wiring testsiz | server.py:2035,2052 |
| R8 | LISP aliasing bypass testi yok | security.py:138-179 |
### ORTA (21 adet)
| # | Konu | Konum |
|---|---|---|
| R9 | entity_array_polar 360° → son kopya 0°=orijinal çakışır | ezdxf_backend.py:946-947 |
| R10 | drawing_finalize fmt belirtmiyor → 3 farklı davranış | server.py:2299 |
| R11 | drawing_save_as(fmt='dwg') ezdxf'de DXF bayt yazar, .dwg ismiyle | ezdxf_backend.py:345-352 |
| R12 | dimension_angular tx/ty yok sayıyor, fixed distance=10 | ezdxf_backend.py:705-722 |
| R13 | entity_offset side_x/side_y her iki backend'de de yok sayılıyor | ezdxf:868-908 / com:860-874 |
| R14 | engineering import guard → None → opaque "NoneType not callable" | server.py:2266,2294 |
| R15 | system_about tool_groups → 15 tool eksik, drifted | server.py:2070-2106 |
| R16 | COM transaction_active sızıntısı hata durumunda | com_backend.py:1402 |
| R17 | _safe_send_command busy-poll STA thread'i bloke ediyor | com_backend.py:1521-1536 |
| R18 | drawing_new bootstrap hatalarını yutuyor, başarı döndürüyor | server.py:280-294 |
| R19 | dimension_aligned/angular kalıcı xfail, kırık araçlar gönderildi | tests/test_dimensions.py:16-27 |
| ... | (+ 10 daha orta öncelikli risk) | |
### DÜŞÜK (22 adet)
| # | Konu | Konum |
|---|---|---|
| R20 | _registered_tool_count FastMCP iç yapısına bağımlı | server.py:231-234 |
| R21 | Involute dişi geometrisi matematiksel hatalı (trochoid fillet yok) | engineering/gear.py:40-50 |
| R22 | undo/transaction stack paylaşıyor, doc_path sıfırlanmıyor | ezdxf_backend.py:470-484 |
| R23 | COM run_lisp her zaman 'nil' döndürüyor | com_backend.py:1487-1492 |
| R24 | _apply_attrs linetype'ı yüklemeden set ediyor | ezdxf_backend.py:491-501 |
| R25 | block_find_references max_list_limit bypass | server.py:1362-1370 |
| R26 | view zoom ezdxf'de no-op ama success döndürüyor | server.py:1814-1831 |
| R27 | validator title check false-positive ('SPUR' içeren notta) | engineering/validator.py:166-181 |
| R28 | COM HWND palette/splash dialog'u yakalayabilir | com_backend.py:283-300 |
| R29 | ~20 sitede except Exception: pass COM hatalarını yutuyor | com_backend.py:199,244,… |
### KOD KOKUSU / ANLAMSIZ KOD (53 adet)
- `engineering/section.py` — tamamen dead code, sıfır caller, sıfır test
- `gear.generate_tooth_profile` — dead code (export edilmiş ama hiç çağrılmıyor)
- `BlockInfo.description` field — hiç doldurulmuyor (her iki backend'de)
- `critique.py:53` "normalises to mm" yorumu — FALSE COMMENT
- `engineering/__init__.py:37-39` "Agent B … in flight" / "Task #7/#8" — çok-agent build seam üretime gönderilmiş
- `_LAYER_TEMPLATES` (server.py) ve prompt layer set'leri çakışıyor/kopyalanmış
- `template_list` / `validation_check` saf in-memory, yeterli test yok
- `system_about` static catalog 15 araç eksik
- ... (+ 40 daha smell)
---
## TEST KAPSAMASI (Eksikler)
1. **COM backend: %0 coverage** — en riskli yer, sıfır test
2. **Server→sanitizer entegrasyon** — araçlar sanitizer'ı gerçekten çağırıyor mu? Test yok
3. **LISP adversarial bypass** — aliasing, UNC path vs test yok
4. **Critique detection paths** — sadece temiz çizimde test edilmiş (hata bulmuyor mu diye değil)
5. `dimension_auto ordinate` — testsiz
6. `dimension_aligned/angular` — kalıcı xfail (kırık gönderilmiş)
7. Pek çok backend metodu hiç test edilmemiyor (system_get/set_variable, drawing_close, view_screenshot, ...)
---
## ARAŞTIRMA ÖZETİ (Ne bulduk)
### Akademik (arXiv 2025-2026)
- **GeoGramBench (ICLR 2026):** LLM'ler koordinat hesaplamada <%50 başarı → `point_from_snap` yaklaşımımızı doğruluyor, genişletmek lazım
- **CADSmith:** 5-agent döngüsü (Planner/Coder/Executor/Validator/Refiner) + VLM render → Chamfer 38× iyileştirme. **Render → VLM judge döngüsü en yüksek kaldıraç**
- **Self-Improving CAD + FEA:** İterasyon per token'dan çok daha değerli; drawing_critique tek seferlik değil döngü olmalı
- **ProCAD:** Çizmeden önce belirsizlik tespiti → hata oranı %14.6 → %0.9
### Rakip MCP'ler
| Rakip | 3D | Render-Critique | Validation Gate |
|---|---|---|---|
| **Bizim (AutoCAD MCP Pro)** | ❌ | ⚠️ finalize-only | ✅ **UNIQUE** |
| build123d-mcp | ✅ | ✅ | ❌ |
| FreeCAD MCP (150+ tool) | ✅ + FEM | ⚠️ | ❌ |
| Blender MCP (22k★) | ✅ | ✅ | ❌ |
| RhinoMCP | ✅ | ✅ | ❌ |
**Moatımız: Standards-validation gate — kimse yapmıyor.**
**Gerideyiz: 3D, in-loop render-critique, HTTP auth.**
---
## KALAN GÖREVLER ⏳
### Task #2 — DIAGNOSTICS Workflow
- Bug'ları `file:line` doğruluğuyla teyit et
- Ölü kodu + anlamsız kodlamaları listele
- Çekişmeli doğrulama (refute) sonrası "onaylanmış bulgu listesi" üret
- **Durum: Bekliyor (RECON tamamlandı, hazır)**
### Task #3 — IDEAS Workflow
- RECON araştırmasından türetilen fikir adayları oluştur
- Impact × Effort × Differentiation skoru
- Önceliklendirilmiş, kanıta dayalı yol haritası
- **Durum: DIAGNOSTICS bitmesini bekliyor**
### Task #4 — Konsolide Rapor + Onay
- RECON + DIAGNOSTICS + IDEAS birleştir
- Kullanıcıya sun
- **Onay sonrası** kodlamaya geç (kullanıcı bu tercihi seçti)
---
## DOSYALAR
| Dosya | İçerik |
|---|---|
| `docs/analysis/00-RECON-dossier.md` | Tam sentez raporu (31KB) |
| `docs/analysis/_recon_raw.json` | Ham yapılandırılmış veri |
| `not.md` | Bu dosya — oturum notları |
---
## ÖNERİLEN FIX ÖNCELİKLERİ (Onay Bekleniyor)
**P0 — Hemen Çalışmaz Olanlar (Onay Sonrası Fix)**
1. R1: `com_backend.py:1637-1660` → 8 premium aracı COM'a port et
2. R2: `ezdxf_backend.py:1034` → `int(lineweight * 100)` veya enum map ile düzelt
3. LICENSE dosyası ekle (legal açık)
4. CI (ci.yml) geri yükle veya yeniden kur
**P1 — Kritik Kalite (Onay Sonrası)**
5. `drawing_critique` → iteratif refiner döngüsü
6. Finalize screenshot'ı VLM judge'a besle
7. `drawing_plan`'a pre-flight ambiguity detector ekle
8. COM testleri: mock veya live fixture
**P2 — Üretim Çizim Kalitesi**
9. ISO-129 toleranslar + GD&T çerçevesi
10. PaperSpace/Layout/Plot desteği (COM)
11. odafc ile gerçek DWG export (ezdxf)
**P3 — Kapsam Genişletme**
12. 3D solid tier (COM extrude/revolve/boolean)
13. Parametrik yeniden sürüş (driving dims → re-drive tool)
14. HTTP API-key auth middleware
+8 -5
View File
@@ -287,10 +287,11 @@ async def drawing_new(
) )
lt_status = await ensure_standard_linetypes(backend) lt_status = await ensure_standard_linetypes(backend)
layer_status = await ensure_engineering_layers(backend) layer_status = await ensure_engineering_layers(backend)
result["bootstrap"] = {"linetypes": lt_status, "layers": layer_status} result["bootstrap"] = {"ok": True, "linetypes": lt_status, "layers": layer_status}
except Exception as exc: except Exception as exc:
await ctx.warning(f"Engineering bootstrap skipped: {exc}") await ctx.warning(f"Engineering bootstrap failed: {exc}")
result["bootstrap"] = {"error": str(exc)} result["bootstrap"] = {"ok": False, "error": str(exc)}
result["status"] = "degraded"
return result return result
@@ -2263,7 +2264,7 @@ async def titleblock_apply_iso_a3(
ctx: Context = None, ctx: Context = None,
) -> dict: ) -> dict:
"""ISO 7200 / A3 (420x297 mm) title block. Title text is used verbatim.""" """ISO 7200 / A3 (420x297 mm) title block. Title text is used verbatim."""
from engineering import apply_iso_a3_titleblock, TitleBlockMetadata from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
backend = _backend(ctx) backend = _backend(ctx)
metadata = TitleBlockMetadata( metadata = TitleBlockMetadata(
title=title, drawing_no=drawing_no, part_no=part_no, material=material, title=title, drawing_no=drawing_no, part_no=part_no, material=material,
@@ -2296,7 +2297,9 @@ async def drawing_finalize(
if save_path: if save_path:
validated = validate_path(save_path, allow_write=True) validated = validate_path(save_path, allow_write=True)
await backend.drawing_save_as(str(validated)) from pathlib import Path as _P
_fmt = _P(str(validated)).suffix.lstrip(".").lower() or "dxf"
await backend.drawing_save_as(str(validated), fmt=_fmt)
if screenshot_path: if screenshot_path:
validated_shot = validate_path(screenshot_path, allow_write=True) validated_shot = validate_path(screenshot_path, allow_write=True)
-2
View File
@@ -13,14 +13,12 @@ async def test_dimension_linear(backend):
assert info.type == "DIMENSION" assert info.type == "DIMENSION"
@pytest.mark.xfail(reason="ezdxf API parameter mismatch in add_aligned_dim", strict=False)
async def test_dimension_aligned(backend): async def test_dimension_aligned(backend):
info = await backend.dimension_aligned(0, 0, 100, 50, 50, 60) info = await backend.dimension_aligned(0, 0, 100, 50, 50, 60)
assert info.handle assert info.handle
assert info.type == "DIMENSION" assert info.type == "DIMENSION"
@pytest.mark.xfail(reason="ezdxf API parameter mismatch in add_angular_dim_2l", strict=False)
async def test_dimension_angular(backend): async def test_dimension_angular(backend):
info = await backend.dimension_angular(0, 0, 100, 0, 0, 100, 50, 50) info = await backend.dimension_angular(0, 0, 100, 0, 0, 100, 50, 50)
assert info.handle assert info.handle
-1
View File
@@ -18,7 +18,6 @@ from engineering.layers import (
ensure_standard_linetypes, ensure_standard_linetypes,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pure math (sync) # Pure math (sync)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -3
View File
@@ -4,7 +4,6 @@ Backend coverage: ezdxf only. COM tests need a live AutoCAD instance and are
skipped unless explicitly enabled. skipped unless explicitly enabled.
""" """
import math
import pytest import pytest
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@@ -151,7 +150,7 @@ class TestChamfer:
# Tangent points: (96,0) and (100,4) in some order. # Tangent points: (96,0) and (100,4) in some order.
pts = sorted([tuple(s[:2]), tuple(e[:2])]) pts = sorted([tuple(s[:2]), tuple(e[:2])])
expected = sorted([(96.0, 0.0), (100.0, 4.0)]) expected = sorted([(96.0, 0.0), (100.0, 4.0)])
for got, exp in zip(pts, expected): for got, exp in zip(pts, expected, strict=True):
assert _close(got[0], exp[0]) and _close(got[1], exp[1]) assert _close(got[0], exp[0]) and _close(got[1], exp[1])
async def test_chamfer_asymmetric(self, backend): async def test_chamfer_asymmetric(self, backend):
@@ -162,7 +161,7 @@ class TestChamfer:
e = cl.properties["end"] e = cl.properties["end"]
pts = sorted([tuple(s[:2]), tuple(e[:2])]) pts = sorted([tuple(s[:2]), tuple(e[:2])])
expected = sorted([(96.0, 0.0), (100.0, 6.0)]) expected = sorted([(96.0, 0.0), (100.0, 6.0)])
for got, exp in zip(pts, expected): for got, exp in zip(pts, expected, strict=True):
assert _close(got[0], exp[0]) and _close(got[1], exp[1]) assert _close(got[0], exp[0]) and _close(got[1], exp[1])
async def test_chamfer_trims_source_lines(self, backend): async def test_chamfer_trims_source_lines(self, backend):
+2 -2
View File
@@ -187,9 +187,9 @@ class TestDimensionAuto:
assert len(dims) == 2 assert len(dims) == 2
async def test_unknown_style_raises(self, backend): async def test_unknown_style_raises(self, backend):
l = await backend.entity_create_line(0, 0, 50, 0) ln = await backend.entity_create_line(0, 0, 50, 0)
with pytest.raises(RuntimeError, match="unknown style"): with pytest.raises(RuntimeError, match="unknown style"):
await backend.dimension_auto([l.handle], style="diagonal") await backend.dimension_auto([ln.handle], style="diagonal")
async def test_empty_handles_returns_empty(self, backend): async def test_empty_handles_returns_empty(self, backend):
result = await backend.dimension_auto([], style="chain") result = await backend.dimension_auto([], style="chain")