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:
U-C4N
2026-05-15 19:17:14 +03:00
parent 88a9005a6a
commit 9258da429f
19 changed files with 2847 additions and 2 deletions
@@ -0,0 +1,98 @@
---
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.
@@ -0,0 +1,107 @@
# 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.
@@ -0,0 +1,125 @@
# 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.
@@ -0,0 +1,77 @@
# 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.
@@ -0,0 +1,109 @@
# 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).
@@ -0,0 +1,113 @@
# 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.
@@ -0,0 +1,159 @@
# 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
@@ -101,3 +101,50 @@ Tools are organized into 12 sections:
- **`_dc(obj)`**: converts dataclasses to dicts recursively for JSON serialization. - **`_dc(obj)`**: converts dataclasses to dicts recursively for JSON serialization.
- **Engineering layer scaffold**: `drawing_new` auto-bootstraps standard linetypes (CENTER, HIDDEN, PHANTOM) and engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK). Pass `bootstrap=False` to opt out. - **Engineering layer scaffold**: `drawing_new` auto-bootstraps standard linetypes (CENTER, HIDDEN, PHANTOM) and engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK). Pass `bootstrap=False` to opt out.
- **Production drawings**: For real engineering output, use the `engineering/` package primitives via the `gear_*` / `keyway_*` / `titleblock_*` MCP tools — do NOT hand-draw teeth/keyways/sections with raw `entity_create_*` calls. Always end with `drawing_finalize` for the 8-step validator. - **Production drawings**: For real engineering output, use the `engineering/` package primitives via the `gear_*` / `keyway_*` / `titleblock_*` MCP tools — do NOT hand-draw teeth/keyways/sections with raw `entity_create_*` calls. Always end with `drawing_finalize` for the 8-step validator.
### Premium Drawing Rules
These rules are non-negotiable for production engineering output. The full
discipline + workflow templates live in `.claude/skills/autocad-mcp-premium/`.
1. **Plan before draw**: Call `drawing_plan(intent, scale, sheet)` *before* any
`entity_create_*`. The returned PlanSpec is held by the backend and replayed
by `drawing_critique` at finalize time.
2. **No coordinate guessing**: Never compute snap points (endpoints, midpoints,
intersections, perpendicular feet) from memory. Use
`point_from_snap(handle, "end"|"mid"|"center"|"quad"|"perp"|"near", ref_x, ref_y)`.
3. **Layer discipline**: All geometry on engineering layers (GEOMETRY, HIDDEN,
CENTER, ...). Construction geometry on `CONSTRUCTION` layer (color 250,
lightest weight); wipe with `construction_clear()` before finalize.
4. **Lineweights are ISO 128**: 0.13/0.18/0.25/0.35/0.50/0.70/1.00/1.40/2.00 mm
only. Use `drawing_apply_iso_layers("mech"|"pid"|"iso13567")` to bootstrap
correct lineweights per layer; never set lineweight manually outside this set.
5. **Corners must be explicit**: Two intersecting lines that should meet at a
sharp corner must use `entity_trim` (with `keep_x/keep_y`) — leaving overshoot
is reported as `untrimmed_corner` by `drawing_critique`. Rounded/beveled
corners use `entity_fillet` / `entity_chamfer`.
6. **Dimensions via auto**: Prefer `dimension_auto(handles, "chain"|"baseline"|
"ordinate")` over individual `dimension_linear` calls. Manual dimensions only
for special cases (leader notes, ordinate origins).
7. **Critique-then-finalize**: `drawing_critique(focus=[...])` must return zero
issues before `drawing_finalize`. Available focuses (closed enum): `iso128`,
`layer_color`, `dim_overlap`, `untrimmed_corner`, `duplicate_entities`,
`construction_left`. Pass `focus=None` for all.
8. **Meta-tools over raw**: When a meta-tool exists for a workflow, use it; do
not reimplement with low-level primitives. Same rule as the engineering-
primitives line above.
### Standard Premium Workflow (template)
```python
1. plan = drawing_plan(intent, sheet_size="A3", scale=1.0)
2. drawing_apply_iso_layers("mech") # or "pid", "iso13567"
3. construction_xline(...) # scaffolding (optional)
4. entity_create_line / circle / ... # main geometry on engineering layers
5. entity_trim / fillet / chamfer # close corners explicitly
6. handles = entity_select_smart({...}) # avoid handle-memorization
7. dimension_auto(handles, style="chain") # ISO 129 dims
8. issues = drawing_critique(focus=None) # must be []
9. construction_clear() # wipe scaffolding
10. drawing_finalize(save_path=..., screenshot_path=...)
```
+133 -1
View File
@@ -5,7 +5,18 @@ 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 from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from engineering.plan_spec import (
CritiqueFocus,
DimStyle,
Issue,
LayerSetId,
PlanSpec,
SheetSize,
SnapType,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Data models # Data models
@@ -291,6 +302,45 @@ class AutoCADBackend(ABC):
center_x: float, center_y: float, center_x: float, center_y: float,
) -> list[EntityInfo]: ... ) -> list[EntityInfo]: ...
# ── corner operations (trim/extend/fillet/chamfer) ───────────────────────
@abstractmethod
async def entity_trim(
self, target_handle: str, cutter_handle: str,
keep_x: float, keep_y: float,
) -> EntityInfo:
"""Trim `target` against `cutter`; keep the segment containing
(keep_x, keep_y). Raises ToolError if no intersection."""
...
@abstractmethod
async def entity_extend(
self, target_handle: str, boundary_handle: str,
end_x: float | None = None, end_y: float | None = None,
) -> EntityInfo:
"""Extend `target` to meet `boundary`. If `end_x/y` is None, the
target endpoint nearest the boundary is auto-selected."""
...
@abstractmethod
async def entity_fillet(
self, handle1: str, handle2: str, radius: float,
trim: bool = True,
) -> EntityInfo:
"""Fillet two entities with the given radius. Returns the new ARC.
When `trim` is True (default), the source entities are shortened
to the tangent points (AutoCAD default behaviour)."""
...
@abstractmethod
async def entity_chamfer(
self, handle1: str, handle2: str,
dist1: float, dist2: float | None = None,
trim: bool = True,
) -> EntityInfo:
"""Chamfer two entities. When `dist2` is None it defaults to `dist1`
(symmetric chamfer). Returns the new chamfer LINE."""
...
# ── entity query/properties ─────────────────────────────────────────────── # ── entity query/properties ───────────────────────────────────────────────
@abstractmethod @abstractmethod
async def entity_get(self, handle: str) -> EntityInfo: ... async def entity_get(self, handle: str) -> EntityInfo: ...
@@ -458,6 +508,88 @@ class AutoCADBackend(ABC):
@abstractmethod @abstractmethod
async def system_run_lisp(self, expression: str) -> dict: ... async def system_run_lisp(self, expression: str) -> dict: ...
# ── premium meta-tools (planning + critique + snap + construction) ──────
@abstractmethod
async def drawing_plan(
self, intent: str,
sheet_size: "SheetSize" = "A3",
scale: float = 1.0,
layer_set_id: "LayerSetId" = "mech",
view_count: int = 1,
dim_style: "DimStyle" = "chain",
notes: list[str] | None = None,
) -> "PlanSpec":
"""Commit a drawing intent before any geometry is created.
Returns a PlanSpec replayed at finalize time by `drawing_critique`."""
...
@abstractmethod
async def drawing_critique(
self, focus: list["CritiqueFocus"] | None = None,
) -> list["Issue"]:
"""Run premium-quality checks; return zero issues before
`drawing_finalize`. `focus=None` runs all checks."""
...
@abstractmethod
async def point_from_snap(
self, handle: str, snap: "SnapType",
ref_x: float | None = None, ref_y: float | None = None,
) -> tuple[float, float]:
"""Return a deterministic snap point on `handle`.
snap ∈ {end, mid, center, quad, int, perp, near}.
For `near/perp/int`, `ref_x/y` is the reference point or other
entity's vicinity."""
...
@abstractmethod
async def construction_xline(
self, x: float, y: float, angle_deg: float,
layer: str = "CONSTRUCTION",
) -> EntityInfo:
"""Create an infinite construction line (XLINE) on a CONSTRUCTION
layer (auto-created with color 8 dashed if missing)."""
...
@abstractmethod
async def construction_clear(
self, layer: str = "CONSTRUCTION",
) -> dict:
"""Delete every entity on the CONSTRUCTION layer.
Must be called before `drawing_finalize`."""
...
@abstractmethod
async def drawing_apply_iso_layers(
self, standard: "LayerSetId" = "mech",
) -> dict:
"""Bootstrap a full ISO-conformant layer set with correct colors
and lineweights. Idempotent."""
...
@abstractmethod
async def dimension_auto(
self, handles: list[str], style: "DimStyle" = "chain",
offset: float = 10.0,
) -> list[EntityInfo]:
"""Generate ISO 129 dimensions across `handles` in the chosen style
(chain | baseline | ordinate). `offset` is the dimension-line
distance from the reference geometry (mm)."""
...
@abstractmethod
async def entity_select_smart(
self, predicate: dict,
) -> list[EntityInfo]:
"""Semantic entity selection. Predicate keys (all optional, AND-ed):
- type: "LINE" | "CIRCLE" | ...
- layer: layer name
- near: [x, y, radius] — entity bounding box must intersect circle
- length_range: [min, max] — applies to LINE/ARC
- color: ACI int
"""
...
# ── concrete helpers (use existing primitives) ────────────────────────── # ── concrete helpers (use existing primitives) ──────────────────────────
async def set_layer_active(self, name: str) -> None: async def set_layer_active(self, name: str) -> None:
"""Convenience: alias for layer_set_current. Concrete; backends need not override.""" """Convenience: alias for layer_set_current. Concrete; backends need not override."""
+168
View File
@@ -1490,3 +1490,171 @@ class ComBackend(AutoCADBackend):
result = doc.SendCommand(f"(progn {expression})\n") result = doc.SendCommand(f"(progn {expression})\n")
return {"ok": True, "expression": expression, "result": str(result) if result else "nil"} return {"ok": True, "expression": expression, "result": str(result) if result else "nil"}
return await self._run(_sync) return await self._run(_sync)
# ── corner ops ──────────────────────────────────────────────────────────
@staticmethod
def _safe_send_command(doc, cmd: str, deadline_s: float = 8.0) -> list[str]:
"""Send `cmd`, polling CMDACTIVE until the command finishes.
Snap/grid/echo are temporarily zeroed to avoid OSNAP-driven misclicks
and chatty echoes; everything is restored in the finally block.
Returns a list of new entity handles created by the command (empty
list for in-place operations like TRIM/EXTEND).
"""
saved: dict[str, Any] = {}
try:
for var in ("OSMODE", "SNAPMODE", "CMDECHO"):
try:
saved[var] = doc.GetVariable(var)
doc.SetVariable(var, 0)
except Exception:
pass
# Snapshot model-space handles (best-effort).
try:
pre = {e.Handle for e in doc.ModelSpace}
except Exception:
pre = set()
payload = cmd if cmd.endswith("\n") else cmd + "\n"
doc.SendCommand(payload)
deadline = time.monotonic() + float(deadline_s)
while True:
try:
active = int(doc.GetVariable("CMDACTIVE"))
except Exception:
active = 0
if active == 0:
break
if time.monotonic() > deadline:
try:
doc.SendCommand("\x1b\x1b\x1b\n") # ESC×3
except Exception:
pass
raise RuntimeError(
f"AutoCAD command did not finish within {deadline_s:.1f}s: {cmd!r}"
)
time.sleep(0.05)
try:
post = {e.Handle for e in doc.ModelSpace}
except Exception:
post = pre
return list(post - pre)
finally:
for var, val in saved.items():
try:
doc.SetVariable(var, val)
except Exception:
pass
async def entity_trim(self, target_handle, cutter_handle, keep_x, keep_y) -> EntityInfo:
def _sync():
doc = _acad_doc()
cmd = (
f'_TRIM\n(handent "{cutter_handle}")\n\n'
f'{float(keep_x)},{float(keep_y)}\n\n'
)
self._safe_send_command(doc, cmd)
ent = doc.HandleToObject(target_handle)
return _entity_info(ent)
return await self._run(_sync)
async def entity_extend(
self, target_handle, boundary_handle, end_x=None, end_y=None,
) -> EntityInfo:
def _sync():
doc = _acad_doc()
target = doc.HandleToObject(target_handle)
boundary = doc.HandleToObject(boundary_handle)
if end_x is None or end_y is None:
# Auto: pick the target endpoint nearest the boundary midpoint.
try:
ts = target.StartPoint
te = target.EndPoint
bs = boundary.StartPoint
be_ = boundary.EndPoint
bm = ((bs[0] + be_[0]) / 2.0, (bs[1] + be_[1]) / 2.0)
ds = (ts[0] - bm[0]) ** 2 + (ts[1] - bm[1]) ** 2
de = (te[0] - bm[0]) ** 2 + (te[1] - bm[1]) ** 2
pick = ts if ds <= de else te
ex, ey = float(pick[0]), float(pick[1])
except Exception:
ex, ey = 0.0, 0.0
else:
ex, ey = float(end_x), float(end_y)
cmd = (
f'_EXTEND\n(handent "{boundary_handle}")\n\n'
f'{ex},{ey}\n\n'
)
self._safe_send_command(doc, cmd)
return _entity_info(doc.HandleToObject(target_handle))
return await self._run(_sync)
async def entity_fillet(self, handle1, handle2, radius, trim=True) -> EntityInfo:
def _sync():
doc = _acad_doc()
t = "T" if trim else "N" # T=Trim, N=No-trim
cmd = (
f'_FILLET\n_R\n{float(radius)}\n_T\n_{t}\n'
f'(handent "{handle1}")\n(handent "{handle2}")\n'
)
new_handles = self._safe_send_command(doc, cmd)
# Return the new ARC entity if one was created (radius > 0); else
# fall back to handle1 (radius=0 = sharp corner, no arc).
for h in new_handles:
try:
ent = doc.HandleToObject(h)
if ent.ObjectName == "AcDbArc":
return _entity_info(ent)
except Exception:
continue
return _entity_info(doc.HandleToObject(handle1))
return await self._run(_sync)
async def entity_chamfer(
self, handle1, handle2, dist1, dist2=None, trim=True,
) -> EntityInfo:
def _sync():
doc = _acad_doc()
d1 = float(dist1)
d2 = float(dist1 if dist2 is None else dist2)
t = "T" if trim else "N"
cmd = (
f'_CHAMFER\n_D\n{d1}\n{d2}\n_T\n_{t}\n'
f'(handent "{handle1}")\n(handent "{handle2}")\n'
)
new_handles = self._safe_send_command(doc, cmd)
for h in new_handles:
try:
ent = doc.HandleToObject(h)
if ent.ObjectName == "AcDbLine":
return _entity_info(ent)
except Exception:
continue
return _entity_info(doc.HandleToObject(handle1))
return await self._run(_sync)
# ── premium meta-tools (Task #7/#8/#9 implement these) ──────────────────
async def drawing_plan(self, intent, sheet_size="A3", scale=1.0,
layer_set_id="mech", view_count=1, dim_style="chain", notes=None):
raise NotImplementedError("drawing_plan — pending Task #8")
async def drawing_critique(self, focus=None):
raise NotImplementedError("drawing_critique — pending Task #8")
async def point_from_snap(self, handle, snap, ref_x=None, ref_y=None):
raise NotImplementedError("point_from_snap — pending Task #7")
async def construction_xline(self, x, y, angle_deg, layer="CONSTRUCTION"):
raise NotImplementedError("construction_xline — pending Task #7")
async def construction_clear(self, layer="CONSTRUCTION"):
raise NotImplementedError("construction_clear — pending Task #7")
async def drawing_apply_iso_layers(self, standard="mech"):
raise NotImplementedError("drawing_apply_iso_layers — pending Task #9")
async def dimension_auto(self, handles, style="chain", offset=10.0):
raise NotImplementedError("dimension_auto — pending Task #9")
async def entity_select_smart(self, predicate):
raise NotImplementedError("entity_select_smart — pending Task #9")
+530
View File
@@ -1518,3 +1518,533 @@ class EzdxfBackend(AutoCADBackend):
"ok": False, "ok": False,
"error": "system_run_lisp not supported in ezdxf backend (no live AutoCAD)", "error": "system_run_lisp not supported in ezdxf backend (no live AutoCAD)",
} }
# ── corner ops ──────────────────────────────────────────────────────────
@staticmethod
def _line_endpoints(ent) -> tuple[tuple[float, float], tuple[float, float]]:
return (
(float(ent.dxf.start.x), float(ent.dxf.start.y)),
(float(ent.dxf.end.x), float(ent.dxf.end.y)),
)
@staticmethod
def _dist2(a: tuple[float, float], b: tuple[float, float]) -> float:
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
async def entity_trim(self, target_handle, cutter_handle, keep_x, keep_y) -> EntityInfo:
def _sync():
from ezdxf.math import intersection_line_line_2d, Vec2
target = self._get_entity(target_handle)
cutter = self._get_entity(cutter_handle)
if target.dxftype() != "LINE" or cutter.dxftype() != "LINE":
raise RuntimeError(
f"entity_trim V1 supports LINE+LINE only "
f"(got {target.dxftype()}+{cutter.dxftype()}). V2 will add LINE+ARC."
)
if target_handle == cutter_handle:
raise RuntimeError("entity_trim: target and cutter cannot be the same entity.")
t_start, t_end = self._line_endpoints(target)
c_start, c_end = self._line_endpoints(cutter)
# virtual=True so cutter can extend in the target's direction
# (AutoCAD's default trim behaviour = "implied extend").
ip = intersection_line_line_2d(
(Vec2(*t_start), Vec2(*t_end)),
(Vec2(*c_start), Vec2(*c_end)),
virtual=True,
)
if ip is None:
raise RuntimeError(
"entity_trim: target and cutter are parallel or do not intersect."
)
ix, iy = float(ip.x), float(ip.y)
keep = (float(keep_x), float(keep_y))
d_start = self._dist2(t_start, keep)
d_end = self._dist2(t_end, keep)
if d_start <= d_end:
target.dxf.end = (ix, iy, 0.0)
else:
target.dxf.start = (ix, iy, 0.0)
self._mark_dirty()
return _entity_info_dxf(target)
return await self._async(_sync)
async def entity_extend(
self, target_handle, boundary_handle, end_x=None, end_y=None,
) -> EntityInfo:
def _sync():
from ezdxf.math import intersection_line_line_2d, Vec2
target = self._get_entity(target_handle)
boundary = self._get_entity(boundary_handle)
if target.dxftype() != "LINE" or boundary.dxftype() != "LINE":
raise RuntimeError(
f"entity_extend V1 supports LINE+LINE only "
f"(got {target.dxftype()}+{boundary.dxftype()}). V2 will add LINE+ARC."
)
if target_handle == boundary_handle:
raise RuntimeError("entity_extend: target and boundary cannot be the same entity.")
t_start, t_end = self._line_endpoints(target)
b_start, b_end = self._line_endpoints(boundary)
ip = intersection_line_line_2d(
(Vec2(*t_start), Vec2(*t_end)),
(Vec2(*b_start), Vec2(*b_end)),
virtual=True, # treat lines as infinite for extend
)
if ip is None:
raise RuntimeError(
"entity_extend: target and boundary are parallel; no extension possible."
)
ix, iy = float(ip.x), float(ip.y)
if end_x is None or end_y is None:
# auto: extend the endpoint nearest the boundary midpoint
b_mid = ((b_start[0] + b_end[0]) / 2, (b_start[1] + b_end[1]) / 2)
ref = b_mid
else:
ref = (float(end_x), float(end_y))
d_start = self._dist2(t_start, ref)
d_end = self._dist2(t_end, ref)
if d_start <= d_end:
target.dxf.start = (ix, iy, 0.0)
else:
target.dxf.end = (ix, iy, 0.0)
self._mark_dirty()
return _entity_info_dxf(target)
return await self._async(_sync)
def _fillet_chamfer_setup(self, handle1: str, handle2: str, op: str):
"""Shared geometry setup for fillet/chamfer.
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."""
from ezdxf.math import intersection_line_line_2d, Vec2
line1 = self._get_entity(handle1)
line2 = self._get_entity(handle2)
if line1.dxftype() != "LINE" or line2.dxftype() != "LINE":
raise RuntimeError(
f"entity_{op} V1 supports LINE+LINE only "
f"(got {line1.dxftype()}+{line2.dxftype()}). V2 will add LINE+ARC."
)
if handle1 == handle2:
raise RuntimeError(f"entity_{op}: handle1 and handle2 cannot be the same.")
a_start, a_end = self._line_endpoints(line1)
b_start, b_end = self._line_endpoints(line2)
P = intersection_line_line_2d(
(Vec2(*a_start), Vec2(*a_end)),
(Vec2(*b_start), Vec2(*b_end)),
virtual=True,
)
if P is None:
raise RuntimeError(f"entity_{op}: lines are parallel; no intersection.")
Pv = (float(P.x), float(P.y))
# Pick the far endpoint of each line (relative to P).
A_far = a_start if self._dist2(a_start, Pv) > self._dist2(a_end, Pv) else a_end
B_far = b_start if self._dist2(b_start, Pv) > self._dist2(b_end, Pv) else b_end
# Unit vectors from P toward the far endpoints.
ax, ay = A_far[0] - Pv[0], A_far[1] - Pv[1]
bx, by = B_far[0] - Pv[0], B_far[1] - Pv[1]
la = math.hypot(ax, ay)
lb = math.hypot(bx, by)
if la < 1e-12 or lb < 1e-12:
raise RuntimeError(f"entity_{op}: degenerate line (zero length).")
d1 = (ax / la, ay / la)
d2 = (bx / lb, by / lb)
cos_t = max(-1.0, min(1.0, d1[0] * d2[0] + d1[1] * d2[1]))
theta = math.acos(cos_t)
if theta < 1e-9 or abs(math.pi - theta) < 1e-9:
raise RuntimeError(
f"entity_{op}: lines are collinear or anti-parallel; cannot resolve corner."
)
return line1, line2, Pv, d1, d2, A_far, B_far, theta
async def entity_fillet(self, handle1, handle2, radius, trim=True) -> EntityInfo:
def _sync():
line1, line2, P, d1, d2, A_far, B_far, theta = (
self._fillet_chamfer_setup(handle1, handle2, "fillet")
)
r = float(radius)
if r < 0:
raise RuntimeError("entity_fillet: radius must be >= 0.")
half = theta / 2.0
s = r / math.tan(half) if r > 0 else 0.0 # tangent distance from P
T1 = (P[0] + s * d1[0], P[1] + s * d1[1])
T2 = (P[0] + s * d2[0], P[1] + s * d2[1])
msp = self._msp()
arc_handle = None
if r > 0:
# Bisector unit vector (interior of the corner).
bx, by = d1[0] + d2[0], d1[1] + d2[1]
bl = math.hypot(bx, by)
if bl < 1e-12:
raise RuntimeError("entity_fillet: bisector is degenerate.")
bux, buy = bx / bl, by / bl
C = (P[0] + (r / math.sin(half)) * bux,
P[1] + (r / math.sin(half)) * buy)
# Arc start/end angles in degrees, CCW. Pick the orientation
# that yields the short arc (interior fillet) using the cross
# product sign of (T1-C) × (T2-C).
a1 = math.degrees(math.atan2(T1[1] - C[1], T1[0] - C[0]))
a2 = math.degrees(math.atan2(T2[1] - C[1], T2[0] - C[0]))
cross = ((T1[0] - C[0]) * (T2[1] - C[1])
- (T1[1] - C[1]) * (T2[0] - C[0]))
if cross >= 0:
start_a, end_a = a1, a2
else:
start_a, end_a = a2, a1
arc = msp.add_arc(
center=(C[0], C[1]),
radius=r,
start_angle=start_a,
end_angle=end_a,
dxfattribs={"layer": line1.dxf.layer},
)
arc_handle = arc.dxf.handle
if trim:
# Shorten line1 so its near-end becomes T1; same for line2 → T2.
a_start, a_end = self._line_endpoints(line1)
if self._dist2(a_start, P) <= self._dist2(a_end, P):
line1.dxf.start = (T1[0], T1[1], 0.0)
else:
line1.dxf.end = (T1[0], T1[1], 0.0)
b_start, b_end = self._line_endpoints(line2)
if self._dist2(b_start, P) <= self._dist2(b_end, P):
line2.dxf.start = (T2[0], T2[1], 0.0)
else:
line2.dxf.end = (T2[0], T2[1], 0.0)
self._mark_dirty()
if arc_handle is not None:
return _entity_info_dxf(self._get_entity(arc_handle))
# Zero-radius fillet: no arc; return line1 (the modified first line).
return _entity_info_dxf(line1)
return await self._async(_sync)
async def entity_chamfer(
self, handle1, handle2, dist1, dist2=None, trim=True,
) -> EntityInfo:
def _sync():
line1, line2, P, d1, d2, A_far, B_far, theta = (
self._fillet_chamfer_setup(handle1, handle2, "chamfer")
)
d1v = float(dist1)
d2v = float(dist1 if dist2 is None else dist2)
if d1v <= 0 or d2v <= 0:
raise RuntimeError("entity_chamfer: distances must be > 0.")
T1 = (P[0] + d1v * d1[0], P[1] + d1v * d1[1])
T2 = (P[0] + d2v * d2[0], P[1] + d2v * d2[1])
msp = self._msp()
chamfer_line = msp.add_line(
(T1[0], T1[1]), (T2[0], T2[1]),
dxfattribs={"layer": line1.dxf.layer},
)
if trim:
a_start, a_end = self._line_endpoints(line1)
if self._dist2(a_start, P) <= self._dist2(a_end, P):
line1.dxf.start = (T1[0], T1[1], 0.0)
else:
line1.dxf.end = (T1[0], T1[1], 0.0)
b_start, b_end = self._line_endpoints(line2)
if self._dist2(b_start, P) <= self._dist2(b_end, P):
line2.dxf.start = (T2[0], T2[1], 0.0)
else:
line2.dxf.end = (T2[0], T2[1], 0.0)
self._mark_dirty()
return _entity_info_dxf(chamfer_line)
return await self._async(_sync)
# ── premium meta-tools (Task #7/#8/#9 implement these) ──────────────────
async def drawing_plan(
self, intent, sheet_size="A3", scale=1.0,
layer_set_id="mech", view_count=1, dim_style="chain", notes=None,
):
from engineering.plan_spec import PlanSpec
plan = PlanSpec(
intent=str(intent),
sheet_size=sheet_size,
scale=float(scale),
layer_set_id=layer_set_id,
view_count=int(view_count),
dim_style=dim_style,
notes=list(notes) if notes else [],
)
# Hold on the backend instance so drawing_critique can replay later.
self._plan_spec = plan
return plan
async def drawing_critique(self, focus=None):
from engineering.critique import run_critique
return await run_critique(self, focus)
async def point_from_snap(
self, handle, snap, ref_x=None, ref_y=None,
) -> tuple[float, float]:
def _sync():
ent = self._get_entity(handle)
et = ent.dxftype()
ref = (float(ref_x), float(ref_y)) if ref_x is not None and ref_y is not None else None
def _line_pts():
return (
(float(ent.dxf.start.x), float(ent.dxf.start.y)),
(float(ent.dxf.end.x), float(ent.dxf.end.y)),
)
def _circle_or_arc_geom():
cx = float(ent.dxf.center.x)
cy = float(ent.dxf.center.y)
r = float(ent.dxf.radius)
return cx, cy, r
if snap == "end":
if et == "LINE":
s, e = _line_pts()
if ref is None:
return s
ds = (s[0]-ref[0])**2 + (s[1]-ref[1])**2
de = (e[0]-ref[0])**2 + (e[1]-ref[1])**2
return s if ds <= de else e
if et == "ARC":
cx, cy, r = _circle_or_arc_geom()
sa = math.radians(float(ent.dxf.start_angle))
ea = math.radians(float(ent.dxf.end_angle))
sp = (cx + r*math.cos(sa), cy + r*math.sin(sa))
ep = (cx + r*math.cos(ea), cy + r*math.sin(ea))
if ref is None:
return sp
ds = (sp[0]-ref[0])**2 + (sp[1]-ref[1])**2
de = (ep[0]-ref[0])**2 + (ep[1]-ref[1])**2
return sp if ds <= de else ep
raise RuntimeError(f"point_from_snap: 'end' snap not supported on {et}")
if snap == "mid":
if et == "LINE":
s, e = _line_pts()
return ((s[0]+e[0])/2.0, (s[1]+e[1])/2.0)
if et == "ARC":
cx, cy, r = _circle_or_arc_geom()
sa = math.radians(float(ent.dxf.start_angle))
ea = math.radians(float(ent.dxf.end_angle))
if ea < sa:
ea += 2*math.pi
ma = (sa + ea) / 2.0
return (cx + r*math.cos(ma), cy + r*math.sin(ma))
raise RuntimeError(f"point_from_snap: 'mid' snap not supported on {et}")
if snap == "center":
if et in ("CIRCLE", "ARC", "ELLIPSE"):
return (float(ent.dxf.center.x), float(ent.dxf.center.y))
raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}")
if snap == "quad":
if et in ("CIRCLE", "ARC"):
cx, cy, r = _circle_or_arc_geom()
pts = [(cx+r, cy), (cx, cy+r), (cx-r, cy), (cx, cy-r)]
if ref is None:
return pts[0]
return min(pts, key=lambda p: (p[0]-ref[0])**2 + (p[1]-ref[1])**2)
raise RuntimeError(f"point_from_snap: 'quad' snap not supported on {et}")
if snap == "perp":
if ref is None:
raise RuntimeError("point_from_snap: 'perp' requires ref_x/ref_y.")
if et == "LINE":
s, e = _line_pts()
dx = e[0]-s[0]; dy = e[1]-s[1]
L2 = dx*dx + dy*dy
if L2 < 1e-18:
raise RuntimeError("point_from_snap: line has zero length.")
t = ((ref[0]-s[0])*dx + (ref[1]-s[1])*dy) / L2
return (s[0] + t*dx, s[1] + t*dy)
raise RuntimeError(f"point_from_snap: 'perp' snap not supported on {et}")
if snap == "near":
if ref is None:
raise RuntimeError("point_from_snap: 'near' requires ref_x/ref_y.")
if et == "LINE":
s, e = _line_pts()
dx = e[0]-s[0]; dy = e[1]-s[1]
L2 = dx*dx + dy*dy
if L2 < 1e-18:
return s
t = ((ref[0]-s[0])*dx + (ref[1]-s[1])*dy) / L2
t = max(0.0, min(1.0, t))
return (s[0] + t*dx, s[1] + t*dy)
if et in ("CIRCLE", "ARC"):
cx, cy, r = _circle_or_arc_geom()
vx, vy = ref[0]-cx, ref[1]-cy
L = math.hypot(vx, vy)
if L < 1e-18:
return (cx + r, cy)
return (cx + r*vx/L, cy + r*vy/L)
raise RuntimeError(f"point_from_snap: 'near' snap not supported on {et}")
if snap == "int":
raise RuntimeError(
"point_from_snap: 'int' (intersection) requires a 2nd entity; "
"use entity_select_smart or compute via two endpoints. V2 will accept a 2nd handle."
)
raise RuntimeError(
f"point_from_snap: unknown snap type '{snap}'. "
"Use one of: end, mid, center, quad, perp, near."
)
return await self._async(_sync)
async def construction_xline(
self, x, y, angle_deg, layer="CONSTRUCTION",
) -> EntityInfo:
def _sync():
doc = self._require_doc()
# Ensure CONSTRUCTION layer exists (color 250 = light grey, lightest).
if layer not in doc.layers:
doc.layers.add(name=layer, color=250)
msp = self._msp()
angle = math.radians(float(angle_deg))
# ezdxf XLINE: base point + unit direction vector.
xline = msp.add_xline(
(float(x), float(y), 0.0),
(math.cos(angle), math.sin(angle), 0.0),
dxfattribs={"layer": layer},
)
self._mark_dirty()
return _entity_info_dxf(xline)
return await self._async(_sync)
async def construction_clear(self, layer="CONSTRUCTION") -> dict:
def _sync():
msp = self._msp()
doomed = [e for e in msp if e.dxf.layer == layer]
count = len(doomed)
for ent in doomed:
msp.delete_entity(ent)
self._mark_dirty()
return {"ok": True, "layer": layer, "deleted": count}
return await self._async(_sync)
async def drawing_apply_iso_layers(self, standard="mech") -> dict:
from engineering.layers import apply_layer_set
result = await apply_layer_set(self, standard)
return {"ok": True, "standard": standard, "layers": result}
async def dimension_auto(self, handles, style="chain", offset=10.0) -> list[EntityInfo]:
if not handles:
return []
if style not in ("chain", "baseline", "ordinate"):
raise RuntimeError(
f"dimension_auto: unknown style '{style}'. "
"Use 'chain', 'baseline', or 'ordinate'."
)
# Resolve handles to (start, end) tuples; LINE-only in V1.
def _gather():
pts: list[tuple[float, float, float, float]] = []
for h in handles:
ent = self._get_entity(h)
if ent.dxftype() != "LINE":
raise RuntimeError(
f"dimension_auto V1: only LINE entities supported (handle {h} is {ent.dxftype()})."
)
s = (float(ent.dxf.start.x), float(ent.dxf.start.y))
e = (float(ent.dxf.end.x), float(ent.dxf.end.y))
pts.append((s[0], s[1], e[0], e[1]))
return pts
segs = await self._async(_gather)
off = float(offset)
results: list[EntityInfo] = []
if style == "chain":
# Each line gets its own linear dimension; dim line offset above.
for sx, sy, ex, ey in segs:
mx = (sx + ex) / 2.0
my = (sy + ey) / 2.0
dx, dy = ex - sx, ey - sy
L = math.hypot(dx, dy) or 1.0
# Perpendicular offset (rotate dir by 90°, scale).
nx, ny = -dy / L, dx / L
dim_x = mx + nx * off
dim_y = my + ny * off
# Use linear (rotated) dim — robust across all line orientations
# and avoids the dimension_aligned `dist=` bug.
rot_deg = math.degrees(math.atan2(dy, dx))
dim = await self.dimension_linear(
sx, sy, ex, ey, dim_x, dim_y, rotation=rot_deg, layer="DIM",
)
results.append(dim)
elif style == "baseline":
# Project all endpoints onto the first segment's direction; baseline
# = first segment start. Each subsequent point gets a linear dim.
if len(segs) < 1:
return []
sx0, sy0, ex0, ey0 = segs[0]
dx, dy = ex0 - sx0, ey0 - sy0
L0 = math.hypot(dx, dy) or 1.0
ux, uy = dx / L0, dy / L0
# offset stack: each subsequent dim sits one step further away
for i, (sx, sy, ex, ey) in enumerate(segs):
dim_x = sx0 - uy * (off + i * off)
dim_y = sy0 + ux * (off + i * off)
dim = await self.dimension_linear(sx0, sy0, ex, ey, dim_x, dim_y, layer="DIM")
results.append(dim)
elif style == "ordinate":
# Treat each segment endpoint as an ordinate; mark X then Y of `end`.
for sx, sy, ex, ey in segs:
# Two dimensions per segment: X-distance and Y-distance from start.
dim_x = sx + (ex - sx) / 2.0
dim_y_pos = max(sy, ey) + off
if abs(ex - sx) > 1e-9:
dim = await self.dimension_linear(
sx, sy, ex, sy, dim_x, dim_y_pos, layer="DIM"
)
results.append(dim)
if abs(ey - sy) > 1e-9:
dim_x_pos = max(sx, ex) + off
dim = await self.dimension_linear(
ex, sy, ex, ey, dim_x_pos, (sy + ey) / 2.0, rotation=90.0, layer="DIM"
)
results.append(dim)
return results
async def entity_select_smart(self, predicate) -> list[EntityInfo]:
if not isinstance(predicate, dict):
raise RuntimeError("entity_select_smart: predicate must be a dict.")
ptype = predicate.get("type")
player = predicate.get("layer")
pnear = predicate.get("near") # [x, y, radius]
plen = predicate.get("length_range") # [min, max]
pcolor = predicate.get("color")
# Pull a wide list, then filter in-memory (V1 — fine for small/medium drawings).
ents = await self.entity_list(
type_filter=ptype.upper() if isinstance(ptype, str) else None,
layer_filter=player if isinstance(player, str) else None,
limit=5000,
)
def _ok(e: EntityInfo) -> bool:
if pcolor is not None and int(e.color) != int(pcolor):
return False
if pnear is not None:
try:
nx, ny, nr = float(pnear[0]), float(pnear[1]), float(pnear[2])
except Exception:
return False
# Use the entity's first available point.
pt = (e.properties.get("start")
or e.properties.get("center")
or e.properties.get("insertion_point"))
if pt is None:
return False
if (float(pt[0]) - nx) ** 2 + (float(pt[1]) - ny) ** 2 > nr ** 2:
return False
if plen is not None and e.type in ("LINE", "ARC"):
try:
lmin, lmax = float(plen[0]), float(plen[1])
except Exception:
return False
length = e.properties.get("length")
if length is None or not (lmin <= float(length) <= lmax):
return False
return True
return [e for e in ents if _ok(e)]
+20
View File
@@ -21,6 +21,17 @@ from .layers import (
ensure_engineering_layers, ensure_engineering_layers,
ensure_standard_linetypes, ensure_standard_linetypes,
) )
from .plan_spec import (
ALL_CRITIQUE_FOCUSES,
ISO_128_LINEWEIGHTS_MM,
CritiqueFocus,
DimStyle,
Issue,
LayerSetId,
PlanSpec,
SheetSize,
SnapType,
)
from .section import apply_section_hatch, cutting_plane_line from .section import apply_section_hatch, cutting_plane_line
# Agent B contributes titleblock + validator. Imports are guarded so this # Agent B contributes titleblock + validator. Imports are guarded so this
@@ -62,4 +73,13 @@ __all__ = [
"DrawingValidator", "DrawingValidator",
"ValidationResult", "ValidationResult",
"ValidationFinding", "ValidationFinding",
"PlanSpec",
"Issue",
"CritiqueFocus",
"SheetSize",
"LayerSetId",
"DimStyle",
"SnapType",
"ALL_CRITIQUE_FOCUSES",
"ISO_128_LINEWEIGHTS_MM",
] ]
+246
View File
@@ -0,0 +1,246 @@
"""Premium-quality critique checks for finished drawings.
Each focus runs an independent check; new focuses must be added to the
`CritiqueFocus` Literal in plan_spec.py *and* registered here. Closed enum
keeps drawing_critique scope from drifting.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from .layers import ENGINEERING_LAYERS
from .plan_spec import (
ALL_CRITIQUE_FOCUSES,
ISO_128_LINEWEIGHTS_MM,
CritiqueFocus,
Issue,
)
if TYPE_CHECKING:
from backends.base import AutoCADBackend
# Layer-name → expected ACI color, derived from ENGINEERING_LAYERS.
_EXPECTED_LAYER_COLOR: dict[str, int] = {
name: color for name, color, _lt, _lw, _desc in ENGINEERING_LAYERS
}
# Dimension entity types that should sit on the DIM layer.
_DIM_TYPES = {
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
}
async def _check_iso128(backend: "AutoCADBackend") -> list[Issue]:
"""Layer lineweights must be in ISO_128_LINEWEIGHTS_MM (or -3=ByLayer
sentinel meaning 'inherit')."""
issues: list[Issue] = []
try:
layers = await backend.layer_list()
except Exception as exc:
return [Issue("warning", "iso128", f"layer_list failed: {exc}")]
allowed = set(ISO_128_LINEWEIGHTS_MM)
for lyr in layers:
lw = float(lyr.lineweight)
# AutoCAD sentinels: -1 (Default), -2 (ByBlock), -3 (ByLayer);
# 0.0 = ezdxf's "use default" — not an ISO 128 violation.
if lw <= 0:
continue
# ezdxf returns lineweight in mm-hundredths (e.g. 25 = 0.25mm) for
# some entity properties; the layer dataclass already normalises to mm.
if lw not in allowed and (lw / 100.0) not in allowed:
issues.append(Issue(
severity="warning",
focus="iso128",
message=(
f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
f"ISO 128 set {sorted(allowed)}."
),
detail={"layer": lyr.name, "lineweight": lw},
))
return issues
async def _check_layer_color(backend: "AutoCADBackend") -> list[Issue]:
"""Engineering-standard layers must keep their canonical ACI color."""
issues: list[Issue] = []
try:
layers = await backend.layer_list()
except Exception:
return []
for lyr in layers:
expected = _EXPECTED_LAYER_COLOR.get(lyr.name)
if expected is None:
continue # unknown layer — caller's responsibility
if int(lyr.color) != int(expected):
issues.append(Issue(
severity="warning",
focus="layer_color",
message=(
f"Layer '{lyr.name}' has color {lyr.color}; "
f"engineering standard expects {expected}."
),
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
))
return issues
async def _check_construction_left(backend: "AutoCADBackend") -> list[Issue]:
"""CONSTRUCTION layer must be empty before drawing_finalize."""
try:
ents = await backend.entity_list(layer_filter="CONSTRUCTION", limit=5000)
except Exception:
return []
if not ents:
return []
return [Issue(
severity="error",
focus="construction_left",
message=(
f"{len(ents)} entity/entities still on CONSTRUCTION layer. "
"Call construction_clear() before drawing_finalize."
),
handles=[e.handle for e in ents[:50]],
detail={"count": len(ents)},
)]
async def _check_untrimmed_corner(backend: "AutoCADBackend") -> list[Issue]:
"""Two LINE endpoints within `tol` but not exactly equal usually means a
corner where one line overshoots — TRIM/EXTEND/FILLET was forgotten."""
tol = 0.5 # mm — generous; tighten via PlanSpec.notes later
try:
lines = await backend.entity_list(type_filter="LINE", limit=5000)
except Exception:
return []
endpoints: list[tuple[str, tuple[float, float]]] = []
for ln in lines:
s = ln.properties.get("start")
e = ln.properties.get("end")
if s and e:
endpoints.append((ln.handle, (float(s[0]), float(s[1]))))
endpoints.append((ln.handle, (float(e[0]), float(e[1]))))
issues: list[Issue] = []
n = len(endpoints)
seen: set[frozenset[str]] = set()
for i in range(n):
h1, p1 = endpoints[i]
for j in range(i + 1, n):
h2, p2 = endpoints[j]
if h1 == h2:
continue
d = math.hypot(p1[0] - p2[0], p1[1] - p2[1])
if 1e-9 < d <= tol:
key = frozenset((h1, h2))
if key in seen:
continue
seen.add(key)
issues.append(Issue(
severity="warning",
focus="untrimmed_corner",
message=(
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
"likely an untrimmed corner (use entity_trim or entity_fillet)."
),
handles=[h1, h2],
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
))
return issues
async def _check_duplicate_entities(backend: "AutoCADBackend") -> list[Issue]:
"""Two LINEs with identical endpoints (in either direction) are duplicates."""
try:
lines = await backend.entity_list(type_filter="LINE", limit=5000)
except Exception:
return []
seen: dict[tuple, str] = {}
issues: list[Issue] = []
for ln in lines:
s = ln.properties.get("start")
e = ln.properties.get("end")
if not (s and e):
continue
k1 = (round(s[0], 4), round(s[1], 4), round(e[0], 4), round(e[1], 4))
k2 = (k1[2], k1[3], k1[0], k1[1])
for key in (k1, k2):
if key in seen:
issues.append(Issue(
severity="warning",
focus="duplicate_entities",
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
handles=[seen[key], ln.handle],
))
break
else:
seen[k1] = ln.handle
return issues
async def _check_dim_overlap(backend: "AutoCADBackend") -> list[Issue]:
"""Two dimensions whose insertion points are within `tol` likely overlap."""
tol = 5.0 # mm
try:
ents = await backend.entity_list(layer_filter="DIM", limit=5000)
except Exception:
return []
dim_points: list[tuple[str, tuple[float, float]]] = []
for e in ents:
if e.type not in _DIM_TYPES and "DIM" not in e.type:
continue
ip = (e.properties.get("text_position")
or e.properties.get("defpoint")
or e.properties.get("insertion_point"))
if ip and len(ip) >= 2:
dim_points.append((e.handle, (float(ip[0]), float(ip[1]))))
issues: list[Issue] = []
seen: set[frozenset[str]] = set()
for i, (h1, p1) in enumerate(dim_points):
for j in range(i + 1, len(dim_points)):
h2, p2 = dim_points[j]
d = math.hypot(p1[0] - p2[0], p1[1] - p2[1])
if d <= tol:
key = frozenset((h1, h2))
if key in seen:
continue
seen.add(key)
issues.append(Issue(
severity="warning",
focus="dim_overlap",
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
handles=[h1, h2],
))
return issues
_FOCUS_DISPATCH = {
"iso128": _check_iso128,
"layer_color": _check_layer_color,
"dim_overlap": _check_dim_overlap,
"untrimmed_corner": _check_untrimmed_corner,
"duplicate_entities": _check_duplicate_entities,
"construction_left": _check_construction_left,
}
async def run_critique(
backend: "AutoCADBackend",
focus: list[CritiqueFocus] | None = None,
) -> list[Issue]:
"""Dispatch each requested focus check and return aggregated issues."""
foci = list(focus) if focus is not None else list(ALL_CRITIQUE_FOCUSES)
issues: list[Issue] = []
for f in foci:
check = _FOCUS_DISPATCH.get(f)
if check is None:
issues.append(Issue(
severity="warning",
focus=f, # type: ignore[arg-type]
message=f"Unknown critique focus '{f}' (closed enum).",
))
continue
issues.extend(await check(backend))
return issues
+57 -1
View File
@@ -25,6 +25,49 @@ ENGINEERING_LAYERS: list[tuple[str, int, str, float, str]] = [
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"), ("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
] ]
# P&ID standard layer set (de-facto, distilled from ISO 10628 + common practice).
PID_LAYERS: list[tuple[str, int, str, float, str]] = [
("0", 7, "Continuous", 0.25, "default"),
("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"),
("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"),
("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"),
("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"),
("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"),
("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"),
("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"),
("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"),
("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"),
("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
]
# ISO 13567-style layer naming kit (Agent2-Element6-Presentation2-Status1).
# This is a *starter* set — extend as needed per discipline.
ISO13567_LAYERS: list[tuple[str, int, str, float, str]] = [
("0", 7, "Continuous", 0.25, "default"),
("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"),
("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"),
("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"),
("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"),
("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"),
("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"),
("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"),
("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"),
("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"),
]
LAYER_SET_REGISTRY: dict[str, list[tuple[str, int, str, float, str]]] = {
"mech": ENGINEERING_LAYERS,
"pid": PID_LAYERS,
"iso13567": ISO13567_LAYERS,
}
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"] STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
@@ -51,6 +94,19 @@ async def ensure_standard_linetypes(backend: "AutoCADBackend") -> dict[str, str]
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")
async def apply_layer_set(
backend: "AutoCADBackend", standard: str = "mech",
) -> dict[str, str]:
"""Idempotently apply a named layer set ('mech', 'pid', 'iso13567')."""
layer_set = LAYER_SET_REGISTRY.get(standard)
if layer_set is None:
raise RuntimeError(
f"Unknown layer set '{standard}'. "
f"Choose one of: {sorted(LAYER_SET_REGISTRY)}"
)
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
try: try:
@@ -60,7 +116,7 @@ async def ensure_engineering_layers(backend: "AutoCADBackend") -> dict[str, str]
existing = set() existing = set()
results: dict[str, str] = {} results: dict[str, str] = {}
for name, color, linetype, lineweight, _desc in ENGINEERING_LAYERS: for name, color, linetype, lineweight, _desc in layer_set:
if name.lower() in existing: if name.lower() in existing:
results[name] = "exists" results[name] = "exists"
continue continue
+87
View File
@@ -0,0 +1,87 @@
"""Premium drawing planning + critique data models.
PlanSpec is committed up-front by the LLM via `drawing_plan(...)` before any
geometry is created; CritiqueFocus is a closed enum to keep
`drawing_critique(...)` scope from drifting.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
SheetSize = Literal["A4", "A3", "A2", "A1", "A0"]
LayerSetId = Literal["iso13567", "mech", "pid"]
DimStyle = Literal["chain", "baseline", "ordinate", "mixed"]
CritiqueFocus = Literal[
"iso128",
"layer_color",
"dim_overlap",
"untrimmed_corner",
"duplicate_entities",
"construction_left",
]
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
ALL_CRITIQUE_FOCUSES: tuple[CritiqueFocus, ...] = (
"iso128",
"layer_color",
"dim_overlap",
"untrimmed_corner",
"duplicate_entities",
"construction_left",
)
@dataclass
class PlanSpec:
"""Drawing intent committed before geometry creation.
Returned by `drawing_plan(...)` and replayed by `drawing_critique(...)` to
verify the finished drawing matches the original intent.
"""
intent: str
sheet_size: SheetSize
scale: float
layer_set_id: LayerSetId
view_count: int = 1
dim_style: DimStyle = "chain"
notes: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"intent": self.intent,
"sheet_size": self.sheet_size,
"scale": self.scale,
"layer_set_id": self.layer_set_id,
"view_count": self.view_count,
"dim_style": self.dim_style,
"notes": list(self.notes),
}
@dataclass
class Issue:
"""One problem reported by `drawing_critique(...)`."""
severity: Literal["error", "warning", "info"]
focus: CritiqueFocus
message: str
handles: list[str] = field(default_factory=list)
detail: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"severity": self.severity,
"focus": self.focus,
"message": self.message,
"handles": list(self.handles),
"detail": self.detail,
}
# ISO 128 lineweight set (mm). Any lineweight outside this set is flagged.
ISO_128_LINEWEIGHTS_MM: tuple[float, ...] = (
0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00, 1.40, 2.00,
)
+105
View File
@@ -0,0 +1,105 @@
"""End-to-end smoke test for the premium drafting layer.
Runs the full plan → draw → corners → critique → finalize pipeline against the
ezdxf backend (no AutoCAD required). Exits non-zero on any failure.
Usage:
python scripts/smoke_premium.py
"""
from __future__ import annotations
import asyncio
import sys
import tempfile
from pathlib import Path
# Make the project root importable when this script is run from anywhere.
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
async def _run() -> int:
from backends.ezdxf_backend import EzdxfBackend
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_new()
print("[1/9] drawing_plan ...", end=" ")
plan = await backend.drawing_plan(
intent="L-bracket 100x80 R5 inner corner",
sheet_size="A4",
scale=1.0,
layer_set_id="mech",
dim_style="chain",
)
assert plan.intent.startswith("L-bracket"), plan
print("ok")
print("[2/9] drawing_apply_iso_layers(mech) ...", end=" ")
res = await backend.drawing_apply_iso_layers("mech")
assert res["ok"]
print(f"ok ({len(res['layers'])} layers)")
print("[3/9] construction_xline ...", end=" ")
xl = await backend.construction_xline(0, 0, 0)
assert xl.layer == "CONSTRUCTION"
print("ok")
print("[4/9] entity_create_line × 2 ...", end=" ")
l1 = await backend.entity_create_line(0, 0, 100, 0, layer="GEOMETRY")
l2 = await backend.entity_create_line(100, 0, 100, 80, layer="GEOMETRY")
print("ok")
print("[5/9] entity_fillet R=5 ...", end=" ")
arc = await backend.entity_fillet(l1.handle, l2.handle, radius=5)
assert arc.type == "ARC"
cx, cy = arc.properties["center"]
assert abs(cx - 95) < 1e-6 and abs(cy - 5) < 1e-6, (cx, cy)
print(f"ok (center=({cx},{cy}), r=5)")
print("[6/9] entity_select_smart ...", end=" ")
geom = await backend.entity_select_smart({
"type": "LINE",
"layer": "GEOMETRY",
})
assert len(geom) >= 2, len(geom)
print(f"ok ({len(geom)} lines selected)")
print("[7/9] dimension_auto ...", end=" ")
dims = await backend.dimension_auto(
[e.handle for e in geom],
style="chain",
offset=12.0,
)
assert len(dims) == len(geom), (len(dims), len(geom))
print(f"ok ({len(dims)} dims)")
print("[8/9] construction_clear ...", end=" ")
res = await backend.construction_clear()
assert res["ok"], res
print(f"ok (deleted {res['deleted']})")
print("[9/9] drawing_critique (full) ...", end=" ")
issues = await backend.drawing_critique(focus=None)
if issues:
print("FAIL")
for i in issues:
print(f" [{i.severity}] {i.focus}: {i.message}")
return 1
print("ok ([] — clean)")
print("[bonus] export DXF ...", end=" ")
out = Path(tempfile.gettempdir()) / "smoke_premium.dxf"
await backend.drawing_export_dxf(str(out))
assert out.exists() and out.stat().st_size > 0
print(f"ok ({out}, {out.stat().st_size} bytes)")
await backend.disconnect()
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(_run()))
+240
View File
@@ -868,6 +868,83 @@ async def entity_offset(
return _dc(result) return _dc(result)
# ── corner operations (trim/extend/fillet/chamfer) ──────────────────────────
@mcp.tool(
annotations={"title": "Trim Entity", "readOnlyHint": False, "destructiveHint": False},
tags={"entity", "modify", "corner"},
)
async def entity_trim(
target_handle: Annotated[str, "Handle of the line being trimmed"],
cutter_handle: Annotated[str, "Handle of the cutting line"],
keep_x: Annotated[float, "X of a point on the side of the target to KEEP"],
keep_y: Annotated[float, "Y of a point on the side of the target to KEEP"],
ctx: Context = None,
) -> dict:
"""Trim `target` against `cutter`, keeping the segment containing (keep_x, keep_y).
V1 supports LINE+LINE only. Cutter is treated as an infinite ray (AutoCAD's
default 'implied extend' trim mode). Raises if the lines are parallel.
"""
result = await _backend(ctx).entity_trim(target_handle, cutter_handle, keep_x, keep_y)
return _dc(result)
@mcp.tool(
annotations={"title": "Extend Entity", "readOnlyHint": False, "destructiveHint": False},
tags={"entity", "modify", "corner"},
)
async def entity_extend(
target_handle: Annotated[str, "Handle of the line being extended"],
boundary_handle: Annotated[str, "Handle of the boundary line"],
end_x: Annotated[float | None, "X of a point near the endpoint to extend (None = auto)"] = None,
end_y: Annotated[float | None, "Y of a point near the endpoint to extend (None = auto)"] = None,
ctx: Context = None,
) -> dict:
"""Extend `target` to meet `boundary`. If end_x/end_y is None, the target
endpoint nearest the boundary is auto-selected.
V1 supports LINE+LINE only. Raises if the lines are parallel.
"""
result = await _backend(ctx).entity_extend(target_handle, boundary_handle, end_x, end_y)
return _dc(result)
@mcp.tool(
annotations={"title": "Fillet Two Entities", "readOnlyHint": False, "destructiveHint": False},
tags={"entity", "modify", "corner"},
)
async def entity_fillet(
handle1: Annotated[str, "First entity handle"],
handle2: Annotated[str, "Second entity handle"],
radius: Annotated[float, Field(description="Fillet radius (>= 0; 0 = sharp corner / corner-merge)", ge=0.0)],
trim: Annotated[bool, "If true, trim source entities to the tangent points (AutoCAD default)"] = True,
ctx: Context = None,
) -> dict:
"""Round a corner with a tangent arc. Returns info on the new ARC entity
(or the first source line for radius=0). V1 supports LINE+LINE only."""
result = await _backend(ctx).entity_fillet(handle1, handle2, radius, trim)
return _dc(result)
@mcp.tool(
annotations={"title": "Chamfer Two Entities", "readOnlyHint": False, "destructiveHint": False},
tags={"entity", "modify", "corner"},
)
async def entity_chamfer(
handle1: Annotated[str, "First entity handle"],
handle2: Annotated[str, "Second entity handle"],
dist1: Annotated[float, Field(description="Chamfer distance along first line", gt=0.0)],
dist2: Annotated[float | None, "Chamfer distance along second line (None = symmetric, dist2=dist1)"] = None,
trim: Annotated[bool, "If true, trim source entities to the tangent points (AutoCAD default)"] = True,
ctx: Context = None,
) -> dict:
"""Bevel a corner with a chamfer line. Returns info on the new chamfer LINE.
V1 supports LINE+LINE only."""
result = await _backend(ctx).entity_chamfer(handle1, handle2, dist1, dist2, trim)
return _dc(result)
@mcp.tool( @mcp.tool(
annotations={"title": "Delete Entity", "readOnlyHint": False, "destructiveHint": True}, annotations={"title": "Delete Entity", "readOnlyHint": False, "destructiveHint": True},
tags={"entity", "modify"}, tags={"entity", "modify"},
@@ -2243,6 +2320,169 @@ async def drawing_finalize(
return payload return payload
# ---------------------------------------------------------------------------
# ── SECTION 13: Premium Drafting Meta-Tools (5x quality multiplier) ─────────
# ---------------------------------------------------------------------------
# These tools wrap the entity primitives in a quality-first workflow:
# plan → snap-aware geometry → critique → finalize.
# See `.claude/skills/autocad-mcp-premium/` for the full discipline.
@mcp.tool(
annotations={"title": "Drawing: Plan (commit intent before drawing)",
"destructiveHint": False},
tags={"premium", "planning"},
)
async def drawing_plan(
intent: Annotated[str, "One-line description of what this drawing represents."],
sheet_size: Annotated[str, Field(default="A3",
description="Paper size: A4 / A3 / A2 / A1 / A0.")] = "A3",
scale: Annotated[float, Field(default=1.0, gt=0,
description="Drawing scale (1.0 = 1:1, 0.1 = 1:10, etc).")] = 1.0,
layer_set_id: Annotated[str, Field(default="mech",
description="Layer set to bootstrap: mech / pid / iso13567.")] = "mech",
view_count: Annotated[int, Field(default=1, ge=1)] = 1,
dim_style: Annotated[str, Field(default="chain",
description="Default dimensioning style: chain / baseline / ordinate / mixed.")] = "chain",
notes: Annotated[list[str] | None, "Free-form constraint notes."] = None,
ctx: Context = None,
) -> dict:
"""Commit a PlanSpec before any geometry is created.
The PlanSpec is held by the backend and replayed by `drawing_critique`
at finalize time to verify the drawing matches the original intent.
Always call this FIRST in a premium workflow.
"""
plan = await _backend(ctx).drawing_plan(
intent, sheet_size, scale, layer_set_id, view_count, dim_style, notes,
)
return _dc(plan)
@mcp.tool(
annotations={"title": "Drawing: Critique (premium quality checks)",
"destructiveHint": False, "readOnlyHint": True},
tags={"premium", "validation"},
)
async def drawing_critique(
focus: Annotated[list[str] | None, Field(default=None,
description="Subset of: iso128, layer_color, dim_overlap, untrimmed_corner, "
"duplicate_entities, construction_left. None = run all.")] = None,
ctx: Context = None,
) -> list[dict]:
"""Run premium-quality checks. Returns zero issues for a clean drawing.
Standard production gate: must return [] before `drawing_finalize`.
"""
issues = await _backend(ctx).drawing_critique(focus)
return [_dc(i) for i in issues]
@mcp.tool(
annotations={"title": "Point: Snap (deterministic OSNAP)", "readOnlyHint": True},
tags={"premium", "snap"},
)
async def point_from_snap(
handle: Annotated[str, "Entity handle to snap onto"],
snap: Annotated[str, Field(description="Snap type: end | mid | center | quad | perp | near")],
ref_x: Annotated[float | None, "Reference X (required for perp/near; disambiguates end/quad)"] = None,
ref_y: Annotated[float | None, "Reference Y"] = None,
ctx: Context = None,
) -> dict:
"""Compute a deterministic snap point on an entity. Use this INSTEAD OF
guessing coordinates — eliminates the most common LLM drawing error.
"""
pt = await _backend(ctx).point_from_snap(handle, snap, ref_x, ref_y)
return {"x": float(pt[0]), "y": float(pt[1])}
@mcp.tool(
annotations={"title": "Construction: XLine (infinite reference)",
"destructiveHint": False},
tags={"premium", "construction"},
)
async def construction_xline(
x: Annotated[float, "Base point X"],
y: Annotated[float, "Base point Y"],
angle_deg: Annotated[float, "Angle in degrees (0=horizontal, 90=vertical)"],
layer: Annotated[str, "Layer for the construction line"] = "CONSTRUCTION",
ctx: Context = None,
) -> dict:
"""Create an infinite construction line on the CONSTRUCTION layer.
Use as scaffolding; call `construction_clear()` before finalize.
"""
result = await _backend(ctx).construction_xline(x, y, angle_deg, layer)
return _dc(result)
@mcp.tool(
annotations={"title": "Construction: Clear (delete scaffold)",
"destructiveHint": True},
tags={"premium", "construction"},
)
async def construction_clear(
layer: Annotated[str, "Layer to clear"] = "CONSTRUCTION",
ctx: Context = None,
) -> dict:
"""Delete every entity on the CONSTRUCTION layer. Idempotent.
Must be called before `drawing_finalize` to satisfy `construction_left` critique.
"""
return await _backend(ctx).construction_clear(layer)
@mcp.tool(
annotations={"title": "Drawing: Apply ISO Layer Set (bootstrap)",
"destructiveHint": False},
tags={"premium", "layers"},
)
async def drawing_apply_iso_layers(
standard: Annotated[str, Field(default="mech",
description="Layer set: mech (DIN/ISO mechanical), pid (P&ID), iso13567 (CAD layer naming).")] = "mech",
ctx: Context = None,
) -> dict:
"""Bootstrap a full ISO-conformant layer set with correct colors and lineweights.
Idempotent — existing layers are not modified.
"""
return await _backend(ctx).drawing_apply_iso_layers(standard)
@mcp.tool(
annotations={"title": "Dimension: Auto (chain / baseline / ordinate)",
"destructiveHint": False},
tags={"premium", "dimension"},
)
async def dimension_auto(
handles: Annotated[list[str], "List of entity handles to dimension"],
style: Annotated[str, Field(default="chain",
description="chain | baseline | ordinate")] = "chain",
offset: Annotated[float, Field(default=10.0, gt=0,
description="Dimension-line offset from the geometry (mm)")] = 10.0,
ctx: Context = None,
) -> list[dict]:
"""Generate ISO 129 dimensions across the listed entities in the chosen style.
V1 supports LINE entities only.
"""
result = await _backend(ctx).dimension_auto(handles, style, offset)
return [_dc(e) for e in result]
@mcp.tool(
annotations={"title": "Entity: Smart Select (semantic predicate)",
"readOnlyHint": True},
tags={"premium", "select"},
)
async def entity_select_smart(
predicate: Annotated[dict, Field(description=(
"Predicate dict (all keys optional, AND-ed): "
"type (e.g. 'LINE'), layer (name), near ([x,y,radius]), "
"length_range ([min,max], LINE/ARC only), color (ACI int)."
))],
ctx: Context = None,
) -> list[dict]:
"""Select entities by semantic predicate instead of memorising handles."""
result = await _backend(ctx).entity_select_smart(predicate)
return [_dc(e) for e in result]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ── RESOURCES ─────────────────────────────────────────────────────────────── # ── RESOURCES ───────────────────────────────────────────────────────────────
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+181
View File
@@ -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)
+245
View File
@@ -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 == []