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.
- **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.
### 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
from abc import ABC, abstractmethod
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
@@ -291,6 +302,45 @@ class AutoCADBackend(ABC):
center_x: float, center_y: float,
) -> 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 ───────────────────────────────────────────────
@abstractmethod
async def entity_get(self, handle: str) -> EntityInfo: ...
@@ -458,6 +508,88 @@ class AutoCADBackend(ABC):
@abstractmethod
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) ──────────────────────────
async def set_layer_active(self, name: str) -> None:
"""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")
return {"ok": True, "expression": expression, "result": str(result) if result else "nil"}
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,
"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_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
# Agent B contributes titleblock + validator. Imports are guarded so this
@@ -62,4 +73,13 @@ __all__ = [
"DrawingValidator",
"ValidationResult",
"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"),
]
# 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"]
@@ -51,6 +94,19 @@ async def ensure_standard_linetypes(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."""
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)
try:
@@ -60,7 +116,7 @@ async def ensure_engineering_layers(backend: "AutoCADBackend") -> dict[str, str]
existing = set()
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:
results[name] = "exists"
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)
# ── 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(
annotations={"title": "Delete Entity", "readOnlyHint": False, "destructiveHint": True},
tags={"entity", "modify"},
@@ -2243,6 +2320,169 @@ async def drawing_finalize(
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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+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 == []