diff --git a/.claude/skills/autocad-mcp-premium/SKILL.md b/.claude/skills/autocad-mcp-premium/SKILL.md new file mode 100644 index 0000000..3c8fc49 --- /dev/null +++ b/.claude/skills/autocad-mcp-premium/SKILL.md @@ -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. diff --git a/.claude/skills/autocad-mcp-premium/examples/pid-simple-loop.md b/.claude/skills/autocad-mcp-premium/examples/pid-simple-loop.md new file mode 100644 index 0000000..05320f3 --- /dev/null +++ b/.claude/skills/autocad-mcp-premium/examples/pid-simple-loop.md @@ -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. diff --git a/.claude/skills/autocad-mcp-premium/examples/premium-shaft.md b/.claude/skills/autocad-mcp-premium/examples/premium-shaft.md new file mode 100644 index 0000000..ba8d369 --- /dev/null +++ b/.claude/skills/autocad-mcp-premium/examples/premium-shaft.md @@ -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. diff --git a/.claude/skills/autocad-mcp-premium/references/corner-ops.md b/.claude/skills/autocad-mcp-premium/references/corner-ops.md new file mode 100644 index 0000000..2611a80 --- /dev/null +++ b/.claude/skills/autocad-mcp-premium/references/corner-ops.md @@ -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. diff --git a/.claude/skills/autocad-mcp-premium/references/iso-standards.md b/.claude/skills/autocad-mcp-premium/references/iso-standards.md new file mode 100644 index 0000000..acb585d --- /dev/null +++ b/.claude/skills/autocad-mcp-premium/references/iso-standards.md @@ -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: `---` + +| 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 (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). diff --git a/.claude/skills/autocad-mcp-premium/references/meta-planning.md b/.claude/skills/autocad-mcp-premium/references/meta-planning.md new file mode 100644 index 0000000..4b03d8a --- /dev/null +++ b/.claude/skills/autocad-mcp-premium/references/meta-planning.md @@ -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: + 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: # exact match + near: [x, y, radius] # entity within radius of point + length_range: [min, max] # LINE/ARC only + color: # exact match +``` + +Returns a list of `EntityInfo`. Use this in lieu of memorising handles when +chaining `dimension_auto` or other operations. diff --git a/.claude/skills/autocad-mcp-premium/references/premium-workflow.md b/.claude/skills/autocad-mcp-premium/references/premium-workflow.md new file mode 100644 index 0000000..e613c7e --- /dev/null +++ b/.claude/skills/autocad-mcp-premium/references/premium-workflow.md @@ -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) +``` diff --git a/CLAUDE.md b/CLAUDE.md index 33f4e48..e31741e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=...) +``` diff --git a/backends/base.py b/backends/base.py index 36c0de2..a2d8a76 100644 --- a/backends/base.py +++ b/backends/base.py @@ -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.""" diff --git a/backends/com_backend.py b/backends/com_backend.py index 9ab3ff4..07da0dd 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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") diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 043db5c..21083a6 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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)] diff --git a/engineering/__init__.py b/engineering/__init__.py index 8bc0fde..abdf0a2 100644 --- a/engineering/__init__.py +++ b/engineering/__init__.py @@ -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", ] diff --git a/engineering/critique.py b/engineering/critique.py new file mode 100644 index 0000000..aca306c --- /dev/null +++ b/engineering/critique.py @@ -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 diff --git a/engineering/layers.py b/engineering/layers.py index 465300a..3ea807e 100644 --- a/engineering/layers.py +++ b/engineering/layers.py @@ -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 diff --git a/engineering/plan_spec.py b/engineering/plan_spec.py new file mode 100644 index 0000000..9f13a1c --- /dev/null +++ b/engineering/plan_spec.py @@ -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, +) diff --git a/scripts/smoke_premium.py b/scripts/smoke_premium.py new file mode 100644 index 0000000..bf1b975 --- /dev/null +++ b/scripts/smoke_premium.py @@ -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())) diff --git a/server.py b/server.py index c191637..346dc8d 100644 --- a/server.py +++ b/server.py @@ -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 ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_entity_corner_ops.py b/tests/test_entity_corner_ops.py new file mode 100644 index 0000000..e991817 --- /dev/null +++ b/tests/test_entity_corner_ops.py @@ -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) diff --git a/tests/test_meta_tools.py b/tests/test_meta_tools.py new file mode 100644 index 0000000..7bf2e0b --- /dev/null +++ b/tests/test_meta_tools.py @@ -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 == []