Add AutoCAD MCP premium skill and backend APIs
Introduce the autocad-mcp-premium skill with workflow docs, examples, and references (.claude/skills/autocad-mcp-premium/*). Add PlanSpec/critique plumbing and engineering helpers (engineering/plan_spec.py, engineering/critique.py) plus tests and a smoke script. Extend backend API (backends/base.py) with corner operations and premium meta-tools (trim/extend/fillet/chamfer, drawing_plan, drawing_critique, point_from_snap, construction_xline/clear, drawing_apply_iso_layers, dimension_auto, entity_select_smart) and implement COM backend corner ops (backends/com_backend.py) with placeholders for meta-tools. Update CLAUDE.md to document the premium drawing rules and standard workflow. Misc: add examples, references, and tests for premium features, and minor server/backend adjustments.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"""Tests for trim/extend/fillet/chamfer (Task #5).
|
||||
|
||||
Backend coverage: ezdxf only. COM tests need a live AutoCAD instance and are
|
||||
skipped unless explicitly enabled.
|
||||
"""
|
||||
|
||||
import math
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _close(a, b, tol=1e-6):
|
||||
return abs(a - b) < tol
|
||||
|
||||
|
||||
# ─── TRIM ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestTrim:
|
||||
async def test_trim_keep_left(self, backend):
|
||||
target = await backend.entity_create_line(0, 0, 100, 0)
|
||||
cutter = await backend.entity_create_line(50, -10, 50, 10)
|
||||
await backend.entity_trim(target.handle, cutter.handle, keep_x=10, keep_y=0)
|
||||
info = await backend.entity_get(target.handle)
|
||||
end = info.properties["end"]
|
||||
# End was at (100,0); should now be at the intersection (50,0).
|
||||
assert _close(end[0], 50.0)
|
||||
assert _close(end[1], 0.0)
|
||||
|
||||
async def test_trim_keep_right(self, backend):
|
||||
target = await backend.entity_create_line(0, 0, 100, 0)
|
||||
cutter = await backend.entity_create_line(50, -10, 50, 10)
|
||||
await backend.entity_trim(target.handle, cutter.handle, keep_x=80, keep_y=0)
|
||||
info = await backend.entity_get(target.handle)
|
||||
start = info.properties["start"]
|
||||
assert _close(start[0], 50.0)
|
||||
assert _close(start[1], 0.0)
|
||||
|
||||
async def test_trim_parallel_raises(self, backend):
|
||||
target = await backend.entity_create_line(0, 0, 100, 0)
|
||||
parallel = await backend.entity_create_line(0, 10, 100, 10)
|
||||
with pytest.raises(RuntimeError, match="parallel"):
|
||||
await backend.entity_trim(target.handle, parallel.handle, 50, 0)
|
||||
|
||||
async def test_trim_same_handle_raises(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
with pytest.raises(RuntimeError, match="same"):
|
||||
await backend.entity_trim(line.handle, line.handle, 50, 0)
|
||||
|
||||
async def test_trim_non_line_raises(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
circle = await backend.entity_create_circle(50, 0, 10)
|
||||
with pytest.raises(RuntimeError, match="LINE\\+LINE only"):
|
||||
await backend.entity_trim(line.handle, circle.handle, 80, 0)
|
||||
|
||||
|
||||
# ─── EXTEND ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestExtend:
|
||||
async def test_extend_auto_endpoint(self, backend):
|
||||
# Target ends at x=40; boundary is vertical at x=80.
|
||||
target = await backend.entity_create_line(0, 0, 40, 0)
|
||||
boundary = await backend.entity_create_line(80, -10, 80, 10)
|
||||
await backend.entity_extend(target.handle, boundary.handle)
|
||||
info = await backend.entity_get(target.handle)
|
||||
end = info.properties["end"]
|
||||
assert _close(end[0], 80.0)
|
||||
assert _close(end[1], 0.0)
|
||||
|
||||
async def test_extend_explicit_endpoint(self, backend):
|
||||
# Target is (50,0)-(100,0); extend the start side to x=20.
|
||||
target = await backend.entity_create_line(50, 0, 100, 0)
|
||||
boundary = await backend.entity_create_line(20, -10, 20, 10)
|
||||
await backend.entity_extend(target.handle, boundary.handle, end_x=50, end_y=0)
|
||||
info = await backend.entity_get(target.handle)
|
||||
start = info.properties["start"]
|
||||
assert _close(start[0], 20.0)
|
||||
|
||||
async def test_extend_parallel_raises(self, backend):
|
||||
target = await backend.entity_create_line(0, 0, 50, 0)
|
||||
parallel = await backend.entity_create_line(0, 10, 50, 10)
|
||||
with pytest.raises(RuntimeError, match="parallel"):
|
||||
await backend.entity_extend(target.handle, parallel.handle)
|
||||
|
||||
|
||||
# ─── FILLET ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestFillet:
|
||||
async def test_fillet_90_degree_corner(self, backend):
|
||||
# L-shape: horizontal (0,0)-(100,0) + vertical (100,0)-(100,80).
|
||||
# Inner fillet R=5 → arc center (95,5), tangent points (95,0) and (100,5).
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80)
|
||||
arc = await backend.entity_fillet(l1.handle, l2.handle, radius=5)
|
||||
assert arc.type == "ARC"
|
||||
cx, cy = arc.properties["center"]
|
||||
assert _close(cx, 95.0)
|
||||
assert _close(cy, 5.0)
|
||||
assert _close(arc.properties["radius"], 5.0)
|
||||
|
||||
async def test_fillet_trims_source_lines(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80)
|
||||
await backend.entity_fillet(l1.handle, l2.handle, radius=5, trim=True)
|
||||
info1 = await backend.entity_get(l1.handle)
|
||||
info2 = await backend.entity_get(l2.handle)
|
||||
end1 = info1.properties["end"]
|
||||
start2 = info2.properties["start"]
|
||||
assert _close(end1[0], 95.0) and _close(end1[1], 0.0)
|
||||
assert _close(start2[0], 100.0) and _close(start2[1], 5.0)
|
||||
|
||||
async def test_fillet_zero_radius(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80)
|
||||
result = await backend.entity_fillet(l1.handle, l2.handle, radius=0)
|
||||
# No arc: result is the source line (corner-merge).
|
||||
assert result.type == "LINE"
|
||||
|
||||
async def test_fillet_no_trim(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80)
|
||||
await backend.entity_fillet(l1.handle, l2.handle, radius=5, trim=False)
|
||||
info1 = await backend.entity_get(l1.handle)
|
||||
# Source line endpoint should be unchanged (still 100,0).
|
||||
end1 = info1.properties["end"]
|
||||
assert _close(end1[0], 100.0)
|
||||
|
||||
async def test_fillet_parallel_raises(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(0, 10, 100, 10)
|
||||
with pytest.raises(RuntimeError, match="parallel"):
|
||||
await backend.entity_fillet(l1.handle, l2.handle, radius=5)
|
||||
|
||||
async def test_fillet_negative_radius_raises(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80)
|
||||
with pytest.raises(RuntimeError, match=">= 0"):
|
||||
await backend.entity_fillet(l1.handle, l2.handle, radius=-1)
|
||||
|
||||
|
||||
# ─── CHAMFER ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestChamfer:
|
||||
async def test_chamfer_symmetric(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80)
|
||||
cl = await backend.entity_chamfer(l1.handle, l2.handle, dist1=4)
|
||||
assert cl.type == "LINE"
|
||||
s = cl.properties["start"]
|
||||
e = cl.properties["end"]
|
||||
# Tangent points: (96,0) and (100,4) in some order.
|
||||
pts = sorted([tuple(s[:2]), tuple(e[:2])])
|
||||
expected = sorted([(96.0, 0.0), (100.0, 4.0)])
|
||||
for got, exp in zip(pts, expected):
|
||||
assert _close(got[0], exp[0]) and _close(got[1], exp[1])
|
||||
|
||||
async def test_chamfer_asymmetric(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80)
|
||||
cl = await backend.entity_chamfer(l1.handle, l2.handle, dist1=4, dist2=6)
|
||||
s = cl.properties["start"]
|
||||
e = cl.properties["end"]
|
||||
pts = sorted([tuple(s[:2]), tuple(e[:2])])
|
||||
expected = sorted([(96.0, 0.0), (100.0, 6.0)])
|
||||
for got, exp in zip(pts, expected):
|
||||
assert _close(got[0], exp[0]) and _close(got[1], exp[1])
|
||||
|
||||
async def test_chamfer_trims_source_lines(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80)
|
||||
await backend.entity_chamfer(l1.handle, l2.handle, dist1=4, trim=True)
|
||||
info1 = await backend.entity_get(l1.handle)
|
||||
info2 = await backend.entity_get(l2.handle)
|
||||
assert _close(info1.properties["end"][0], 96.0)
|
||||
assert _close(info2.properties["start"][1], 4.0)
|
||||
|
||||
async def test_chamfer_zero_distance_raises(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80)
|
||||
with pytest.raises(RuntimeError, match="> 0"):
|
||||
await backend.entity_chamfer(l1.handle, l2.handle, dist1=0)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tests for the premium meta-tool layer (Task #10).
|
||||
|
||||
Coverage: drawing_plan, drawing_critique, point_from_snap, construction_*,
|
||||
drawing_apply_iso_layers, dimension_auto, entity_select_smart.
|
||||
Plus a small integration pipeline: plan → draw → critique.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _close(a, b, tol=1e-6):
|
||||
return abs(a - b) < tol
|
||||
|
||||
|
||||
# ─── drawing_plan ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestDrawingPlan:
|
||||
async def test_plan_returns_spec_with_intent(self, backend):
|
||||
plan = await backend.drawing_plan(
|
||||
"L-bracket 50x80", sheet_size="A4", scale=1.0,
|
||||
)
|
||||
assert plan.intent == "L-bracket 50x80"
|
||||
assert plan.sheet_size == "A4"
|
||||
assert plan.scale == 1.0
|
||||
assert plan.layer_set_id == "mech"
|
||||
|
||||
async def test_plan_to_dict(self, backend):
|
||||
plan = await backend.drawing_plan("X", sheet_size="A3", scale=2.0)
|
||||
d = plan.to_dict()
|
||||
assert d["intent"] == "X"
|
||||
assert d["sheet_size"] == "A3"
|
||||
assert d["notes"] == []
|
||||
|
||||
|
||||
# ─── drawing_critique ──────────────────────────────────────────────────────
|
||||
|
||||
class TestDrawingCritique:
|
||||
async def test_critique_clean_drawing(self, backend):
|
||||
# Bootstrap layer set so layer_color/iso128 don't trigger.
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
issues = await backend.drawing_critique()
|
||||
assert issues == []
|
||||
|
||||
async def test_critique_construction_left(self, backend):
|
||||
await backend.construction_xline(0, 0, 0)
|
||||
issues = await backend.drawing_critique(focus=["construction_left"])
|
||||
assert len(issues) == 1
|
||||
assert issues[0].focus == "construction_left"
|
||||
assert issues[0].severity == "error"
|
||||
|
||||
async def test_critique_construction_clear_resets(self, backend):
|
||||
await backend.construction_xline(0, 0, 0)
|
||||
await backend.construction_clear()
|
||||
issues = await backend.drawing_critique(focus=["construction_left"])
|
||||
assert issues == []
|
||||
|
||||
async def test_critique_untrimmed_corner(self, backend):
|
||||
# Two lines with a 0.1 mm gap — should flag.
|
||||
await backend.entity_create_line(0, 0, 50, 0)
|
||||
await backend.entity_create_line(50.1, 0, 50.1, 50)
|
||||
issues = await backend.drawing_critique(focus=["untrimmed_corner"])
|
||||
assert len(issues) >= 1
|
||||
assert all(i.focus == "untrimmed_corner" for i in issues)
|
||||
|
||||
async def test_critique_duplicate_entities(self, backend):
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
issues = await backend.drawing_critique(focus=["duplicate_entities"])
|
||||
assert len(issues) >= 1
|
||||
|
||||
|
||||
# ─── point_from_snap ───────────────────────────────────────────────────────
|
||||
|
||||
class TestPointFromSnap:
|
||||
async def test_snap_end_default(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
pt = await backend.point_from_snap(line.handle, "end")
|
||||
assert pt == (0.0, 0.0)
|
||||
|
||||
async def test_snap_end_with_ref(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
pt = await backend.point_from_snap(line.handle, "end", ref_x=90, ref_y=0)
|
||||
assert pt == (100.0, 0.0)
|
||||
|
||||
async def test_snap_mid(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
pt = await backend.point_from_snap(line.handle, "mid")
|
||||
assert pt == (50.0, 0.0)
|
||||
|
||||
async def test_snap_perp(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
pt = await backend.point_from_snap(line.handle, "perp", ref_x=50, ref_y=30)
|
||||
assert pt == (50.0, 0.0)
|
||||
|
||||
async def test_snap_near_clamped(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
pt = await backend.point_from_snap(line.handle, "near", ref_x=200, ref_y=30)
|
||||
assert pt == (100.0, 0.0)
|
||||
|
||||
async def test_snap_circle_center(self, backend):
|
||||
c = await backend.entity_create_circle(50, 50, 10)
|
||||
pt = await backend.point_from_snap(c.handle, "center")
|
||||
assert pt == (50.0, 50.0)
|
||||
|
||||
async def test_snap_circle_quad_east(self, backend):
|
||||
c = await backend.entity_create_circle(50, 50, 10)
|
||||
pt = await backend.point_from_snap(c.handle, "quad", ref_x=70, ref_y=50)
|
||||
assert pt == (60.0, 50.0)
|
||||
|
||||
async def test_snap_unknown_raises(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
with pytest.raises(RuntimeError, match="unknown snap"):
|
||||
await backend.point_from_snap(line.handle, "moonbeam")
|
||||
|
||||
async def test_snap_perp_requires_ref(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
with pytest.raises(RuntimeError, match="ref_x"):
|
||||
await backend.point_from_snap(line.handle, "perp")
|
||||
|
||||
|
||||
# ─── construction layer ───────────────────────────────────────────────────
|
||||
|
||||
class TestConstructionLayer:
|
||||
async def test_xline_creates_on_construction_layer(self, backend):
|
||||
xl = await backend.construction_xline(0, 0, 45)
|
||||
assert xl.layer == "CONSTRUCTION"
|
||||
|
||||
async def test_clear_removes_xlines(self, backend):
|
||||
await backend.construction_xline(0, 0, 0)
|
||||
await backend.construction_xline(10, 10, 90)
|
||||
result = await backend.construction_clear()
|
||||
assert result["deleted"] == 2
|
||||
|
||||
async def test_clear_empty_layer_is_zero(self, backend):
|
||||
result = await backend.construction_clear()
|
||||
assert result["deleted"] == 0
|
||||
|
||||
|
||||
# ─── drawing_apply_iso_layers ──────────────────────────────────────────────
|
||||
|
||||
class TestApplyISOLayers:
|
||||
async def test_mech_layer_set(self, backend):
|
||||
result = await backend.drawing_apply_iso_layers("mech")
|
||||
assert result["ok"] is True
|
||||
layers = await backend.layer_list()
|
||||
names = {lyr.name for lyr in layers}
|
||||
assert {"GEOMETRY", "DIM", "CENTER", "HIDDEN", "CONSTRUCTION"}.issubset(names)
|
||||
|
||||
async def test_pid_layer_set(self, backend):
|
||||
result = await backend.drawing_apply_iso_layers("pid")
|
||||
assert result["ok"] is True
|
||||
layers = await backend.layer_list()
|
||||
names = {lyr.name for lyr in layers}
|
||||
assert "PROCESS-PIPING-MAIN" in names
|
||||
assert "INSTRUMENT-SYMBOL" in names
|
||||
|
||||
async def test_iso13567_layer_set(self, backend):
|
||||
result = await backend.drawing_apply_iso_layers("iso13567")
|
||||
assert result["ok"] is True
|
||||
layers = await backend.layer_list()
|
||||
names = {lyr.name for lyr in layers}
|
||||
assert "M-GEOMET-E-N" in names
|
||||
|
||||
async def test_bad_standard_raises(self, backend):
|
||||
with pytest.raises(RuntimeError, match="Unknown layer set"):
|
||||
await backend.drawing_apply_iso_layers("mars")
|
||||
|
||||
|
||||
# ─── dimension_auto ────────────────────────────────────────────────────────
|
||||
|
||||
class TestDimensionAuto:
|
||||
async def test_chain_creates_one_dim_per_segment(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 30, 0)
|
||||
l2 = await backend.entity_create_line(30, 0, 70, 0)
|
||||
l3 = await backend.entity_create_line(70, 0, 100, 0)
|
||||
dims = await backend.dimension_auto(
|
||||
[l1.handle, l2.handle, l3.handle], style="chain",
|
||||
)
|
||||
assert len(dims) == 3
|
||||
|
||||
async def test_baseline_creates_dims(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 30, 0)
|
||||
l2 = await backend.entity_create_line(30, 0, 70, 0)
|
||||
dims = await backend.dimension_auto([l1.handle, l2.handle], style="baseline")
|
||||
assert len(dims) == 2
|
||||
|
||||
async def test_unknown_style_raises(self, backend):
|
||||
l = await backend.entity_create_line(0, 0, 50, 0)
|
||||
with pytest.raises(RuntimeError, match="unknown style"):
|
||||
await backend.dimension_auto([l.handle], style="diagonal")
|
||||
|
||||
async def test_empty_handles_returns_empty(self, backend):
|
||||
result = await backend.dimension_auto([], style="chain")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ─── entity_select_smart ───────────────────────────────────────────────────
|
||||
|
||||
class TestEntitySelectSmart:
|
||||
async def test_select_by_type(self, backend):
|
||||
await backend.entity_create_line(0, 0, 50, 0)
|
||||
await backend.entity_create_line(0, 50, 50, 50)
|
||||
await backend.entity_create_circle(25, 25, 5)
|
||||
result = await backend.entity_select_smart({"type": "LINE"})
|
||||
assert len(result) == 2
|
||||
|
||||
async def test_select_by_layer(self, backend):
|
||||
await backend.layer_create("ALPHA", color=2)
|
||||
await backend.entity_create_line(0, 0, 50, 0, layer="ALPHA")
|
||||
await backend.entity_create_line(0, 50, 50, 50) # default layer
|
||||
result = await backend.entity_select_smart({"layer": "ALPHA"})
|
||||
assert len(result) == 1
|
||||
|
||||
async def test_select_by_near(self, backend):
|
||||
await backend.entity_create_line(0, 0, 10, 0) # near origin
|
||||
await backend.entity_create_line(1000, 1000, 1010, 1000) # far
|
||||
result = await backend.entity_select_smart({"type": "LINE", "near": [0, 0, 50]})
|
||||
assert len(result) == 1
|
||||
|
||||
async def test_select_by_length_range(self, backend):
|
||||
await backend.entity_create_line(0, 0, 10, 0) # length 10
|
||||
await backend.entity_create_line(0, 50, 100, 50) # length 100
|
||||
result = await backend.entity_select_smart({"type": "LINE", "length_range": [50, 200]})
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# ─── Integration: plan → draw → critique pipeline ──────────────────────────
|
||||
|
||||
class TestPremiumPipeline:
|
||||
async def test_full_l_bracket_pipeline_clean(self, backend):
|
||||
"""plan → apply_layers → draw + fillet → clear construction → critique = []."""
|
||||
plan = await backend.drawing_plan("L-bracket", sheet_size="A4", scale=1.0)
|
||||
assert plan.intent == "L-bracket"
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0, layer="GEOMETRY")
|
||||
l2 = await backend.entity_create_line(100, 0, 100, 80, layer="GEOMETRY")
|
||||
await backend.entity_fillet(l1.handle, l2.handle, radius=5)
|
||||
await backend.construction_clear()
|
||||
# Closed corner with fillet, no construction left, no duplicates.
|
||||
issues = await backend.drawing_critique(
|
||||
focus=["construction_left", "untrimmed_corner", "duplicate_entities"],
|
||||
)
|
||||
assert issues == []
|
||||
Reference in New Issue
Block a user