v1.2.0
Adds production-grade engineering features and tools: 2D GD&T (feature control frames + datum features) via engineering/gdt.py, ISO‑129 dimension tolerance support (engineering/tolerances.py) and an ezdxf/COM plumbing for toleranced dims, a scalar drawing score + invalidity ratio (engineering/scoring.py) wired into drawing_finalize, in-place entity editors (entity_edit_text/entity_edit_geometry), and a friendly drawing_settings facade. Includes server tool wiring, COM/ezdxf backend implementations, datum-consistency critique, many unit tests, README/CLAUDE/CHANGELOG updates, and a version bump to 1.2.0. Also corrects COM backend capability label (entities_2d).
This commit is contained in:
+40
-1
@@ -7,7 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_Next: 1.2 — closed-loop validation moat (scalar drawing-score, pre-plan clarification, iterative refiner + transaction-stack isolation)._
|
||||
_Next: closed-loop refiner (critique→repair→re-critique with transaction-stack isolation), pre-plan clarification pass, ISO 286 fit-table lookup (H7/g6 → deviations), and a local-VLM visual judge._
|
||||
|
||||
## [1.2.0] — 2026-07-07
|
||||
|
||||
Production-ISO parity + a measurable quality moat. **360 tests, ruff-clean.** Tool count: 111 → 116.
|
||||
|
||||
### Added
|
||||
- **`drawing_settings`** — read or change common AutoCAD drawing settings by
|
||||
friendly name (units mm/cm/m/inch/feet, linear/angular precision, LTSCALE,
|
||||
DIMSCALE, text size, point mode/size, OSMODE, fillet radius) without
|
||||
memorising system-variable names. Call with no argument for a full snapshot.
|
||||
A convenience facade over `system_get_variable` / `system_set_variable`;
|
||||
cross-backend.
|
||||
- **In-place editing** — `entity_edit_text` re-labels or resizes an existing
|
||||
TEXT/MTEXT entity, and `entity_edit_geometry` re-drives a CIRCLE/LINE/ARC
|
||||
(center, radius, endpoints, arc angles) — both **preserve the entity handle**,
|
||||
so the user can adjust a drawing without delete-and-recreate. Cross-backend.
|
||||
- **2D GD&T (ISO 1101 / ASME Y14.5)** — `gd_frame` draws a feature control frame
|
||||
(all 14 geometric characteristics, ⌀ zone prefix, Ⓜ/Ⓛ/Ⓢ material modifiers,
|
||||
multi-datum references) and `datum_feature` places a datum symbol. Frames are
|
||||
composed from LINE + TEXT so they render identically on **both** the COM and
|
||||
ezdxf backends (ezdxf's native TOLERANCE entity renders blank via matplotlib).
|
||||
No competing CAD MCP or surveyed text-to-CAD product ships 2D GD&T authoring.
|
||||
- **GD&T datum-consistency gate** — a new `gdt` critique focus fails
|
||||
`drawing_finalize` when a feature control frame references a datum with no
|
||||
matching datum feature (a meaningless FCF per ISO 1101).
|
||||
- **ISO 129 dimension tolerances** — `dimension_linear` / `dimension_radius` /
|
||||
`dimension_diameter` gain `tol_upper` / `tol_lower` / `tol_mode`
|
||||
(`symmetric` ±, `deviation` +a/-b, `limit` stacked, `basic` boxed) and a
|
||||
`text_override` (e.g. `⌀20 H7`). Toleranced production dimensions are now
|
||||
possible — previously the single biggest functional gap.
|
||||
- **Scalar drawing-score + invalidity ratio** (`engineering/scoring.py`) — the
|
||||
`drawing_finalize` payload now carries a 0-100 `score`, an `invalidity_ratio`,
|
||||
and an A-F `grade` over the union of the structural validator and the premium
|
||||
critique, so drawing quality is regression-trackable (MUSE / CadBench grade an
|
||||
Invalidity Ratio, not shape).
|
||||
|
||||
### Fixed
|
||||
- **Honesty**: the COM backend advertised a false `all_entity_types` capability
|
||||
(it authors 2D entities only, no 3D solids) — corrected to `entities_2d`.
|
||||
|
||||
## [1.1.1] — 2026-06-20
|
||||
|
||||
|
||||
@@ -73,15 +73,21 @@ Tools are organized into 12 sections:
|
||||
1. Drawing Management (11 tools): `drawing_*`
|
||||
2. Entity Creation (13 tools): `entity_create_*`
|
||||
3. Dimensions (5 tools): `dimension_*`
|
||||
4. Entity Modification (10 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`
|
||||
4. Entity Modification (12 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`, plus in-place `entity_edit_text` (TEXT/MTEXT content/height/rotation) and `entity_edit_geometry` (CIRCLE/LINE/ARC — center/radius/endpoints/angles), both handle-preserving
|
||||
5. Entity Query (3 tools): `entity_get`, `entity_list`, `entity_delete_many`
|
||||
6. Layer Management (12 tools + 2 linetype_*): `layer_*`, `linetype_list`, `linetype_load`
|
||||
7. Block Operations (7 tools): `block_*`
|
||||
8. Analysis & Query (8 tools): `analysis_*`
|
||||
9. View & Screenshot (5 tools — includes `view_zoom_and_screenshot`): `view_*`
|
||||
10. Transactions (3 tools): `transaction_begin/commit/rollback`
|
||||
11. System (6 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about`
|
||||
11. System (7 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about`, plus `drawing_settings` (friendly units/scale/precision/osnap facade over system variables)
|
||||
12. Engineering / Deterministic CAD (7 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize`
|
||||
13. Premium meta-tools: `drawing_plan`, `drawing_critique`, `point_from_snap/intersection/tangent`, `construction_*`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`
|
||||
14. GD&T (ISO 1101 / ASME Y14.5): `gd_frame` (feature control frames), `datum_feature` — enforced by the `gdt` critique focus
|
||||
|
||||
**ISO 129 tolerances:** `dimension_linear` / `dimension_radius` / `dimension_diameter` take `tol_upper` / `tol_lower` / `tol_mode` (`symmetric` ± / `deviation` +a/-b / `limit` / `basic`) and `text_override` (e.g. `⌀20 H7`). Prefer these over hand-drawn tolerance text.
|
||||
|
||||
**Drawing score:** `drawing_finalize` returns `payload["score"]` — a 0-100 scalar + `invalidity_ratio` + A-F `grade` over the validator + critique union. Use it as the objective quality metric.
|
||||
|
||||
### Adding a New Tool
|
||||
|
||||
|
||||
@@ -3,7 +3,32 @@
|
||||
**Production-grade AutoCAD automation for the Model Context Protocol.**
|
||||
Dual-engine. Battle-tested. Model-agnostic.
|
||||
|
||||
v1.1.1 · 111 tools · 5 resources · 5 prompt templates · COM + ezdxf backends · Python 3.11+ · MIT
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](pyproject.toml)
|
||||
[](https://github.com/jlowin/fastmcp)
|
||||
[](tests/)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://github.com/U-C4N/Autocad-MCP/stargazers)
|
||||
|
||||
> **116 tools · 5 resources · 5 prompt templates · COM + ezdxf backends**
|
||||
>
|
||||
> One MCP contract, two execution engines: drive a **live AutoCAD** instance over COM, or run **fully headless** on any OS with ezdxf — same tool surface, same response shapes.
|
||||
|
||||
**New in 1.2** — 2D GD&T feature control frames + datum features (ISO 1101), ISO 129 dimension tolerances (±/deviation/limit/basic + text override), in-place editing of existing text and geometry (`entity_edit_text` / `entity_edit_geometry`), a friendly `drawing_settings` facade (units/scale/precision/osnap), and a scalar drawing-score + invalidity-ratio at `drawing_finalize`.
|
||||
|
||||
### What makes it different
|
||||
|
||||
| | AutoCAD MCP Pro | Typical AutoCAD MCP |
|
||||
|---|:---:|:---:|
|
||||
| Live **COM** + headless **ezdxf** (one API) | ✅ | one or the other |
|
||||
| **Standards-validation gate** (ISO 128 critique → finalize) | ✅ | ❌ |
|
||||
| **2D GD&T** authoring + datum-consistency gate (ISO 1101) | ✅ | ❌ |
|
||||
| **ISO 129 dimension tolerances** (±/limit/fit text) | ✅ | ❌ |
|
||||
| Deterministic OSNAP (no coordinate guessing) | ✅ | ❌ |
|
||||
| Scalar **drawing-score** / invalidity-ratio | ✅ | ❌ |
|
||||
| Security-hardened (AutoLISP allowlist, path guards, HTTP auth) | ✅ | rare |
|
||||
| Test suite | **360** | few / none |
|
||||
|
||||
---
|
||||
|
||||
@@ -15,7 +40,7 @@ My day job at **Anka-Makine** revolves around intensive AutoCAD work — product
|
||||
|
||||
After months of leaning on this server inside my own daily workflow — and watching it shave hours off my week without a single misstep on critical drawings — I decided it deserved a public release. It works. It is fast. It stays out of the way.
|
||||
|
||||
The model on the other end of the wire does not matter. The Model Context Protocol is the contract; AutoCAD MCP Pro is one well-typed implementation of that contract. Any MCP-aware client — and the LLM behind it — sees the same 111 tools.
|
||||
The model on the other end of the wire does not matter. The Model Context Protocol is the contract; AutoCAD MCP Pro is one well-typed implementation of that contract. Any MCP-aware client — and the LLM behind it — sees the same 116 tools.
|
||||
|
||||
I will keep this repository actively maintained as my own use of it evolves, and **a public benchmark suite is on the way** — I want to give you numbers, not adjectives.
|
||||
|
||||
@@ -26,6 +51,7 @@ I will keep this repository actively maintained as my own use of it evolves, and
|
||||
## Table of Contents
|
||||
|
||||
- [Why This Exists](#why-this-exists)
|
||||
- [Why It's the Best AutoCAD MCP](#why-its-the-best-autocad-mcp)
|
||||
- [Highlights](#highlights)
|
||||
- [AI Client Compatibility](#ai-client-compatibility)
|
||||
- [Quickstart](#quickstart)
|
||||
@@ -56,6 +82,78 @@ AutoCAD MCP Pro takes a different stance:
|
||||
|
||||
---
|
||||
|
||||
## Why It's the Best AutoCAD MCP
|
||||
|
||||
The AutoCAD-MCP field is crowded, but it clusters into a familiar shape: a
|
||||
COM-only, Windows-only script with a handful of `draw_line` / `draw_circle`
|
||||
tools, no tests, no security model, and — crucially — **no idea whether the
|
||||
drawing it produced is actually correct.** Those projects let an LLM *push
|
||||
geometry*. AutoCAD MCP Pro is built to let an LLM *produce a drawing that passes
|
||||
review.* That difference is the whole thesis, and it shows up in five places no
|
||||
other AutoCAD MCP covers at once:
|
||||
|
||||
### 1. It is the only one that checks its own work
|
||||
|
||||
Every competing AutoCAD/CAD MCP surveyed — COM scripts, the LT/AutoLISP servers,
|
||||
the multi-CAD COM bridges — will happily emit a drawing with a leftover
|
||||
construction line, a non-standard lineweight, an untrimmed corner, or two
|
||||
dimensions stacked on top of each other, and report success. AutoCAD MCP Pro
|
||||
runs a **closed quality gate** at `drawing_finalize`: an 8-step structural
|
||||
validator **plus** an ISO-128 premium critique (lineweights, layer colours,
|
||||
untrimmed corners, duplicate entities, leftover scaffolding, dimension overlap,
|
||||
and GD&T datum consistency). Leftover scaffolding or an off-standard lineweight
|
||||
**blocks the gate** instead of slipping into a review. This design-rule
|
||||
validation is the durable moat — and it is exactly the capability Autodesk's own
|
||||
in-AutoCAD AI demo leads with (automated compliance checking).
|
||||
|
||||
### 2. It speaks real engineering, not just geometry
|
||||
|
||||
- **2D GD&T (ISO 1101 / ASME Y14.5)** — feature control frames and datum
|
||||
features, with a gate that rejects a frame referencing an undefined datum. No
|
||||
competing AutoCAD MCP, and none of the surveyed text-to-CAD products, ships 2D
|
||||
GD&T authoring *with validation*.
|
||||
- **ISO 129 dimension tolerances** — `±` / deviation / limit / basic callouts
|
||||
and fit text (`⌀20 H7`). A drawing without tolerances is not manufacturable;
|
||||
most MCPs cannot express them at all.
|
||||
- **Deterministic engineering primitives** — involute gears (front view +
|
||||
section A-A), DIN 6885 keyed bores, ISO 7200 title blocks — parametric, not
|
||||
hand-drawn line-by-line by the model.
|
||||
|
||||
### 3. It removes the #1 LLM drawing error: guessed coordinates
|
||||
|
||||
Research on program-to-geometry (GeoGramBench, 2026) shows frontier models score
|
||||
under 50% when they have to *compute* points. AutoCAD MCP Pro gives the model
|
||||
**deterministic OSNAP** instead — `point_from_snap`, `point_intersection`,
|
||||
`point_tangent` return exact endpoints, intersections, and tangents so the model
|
||||
never guesses a coordinate. `selection_get` even reads the user's live pick set,
|
||||
so the AI edits exactly what was selected.
|
||||
|
||||
### 4. It runs where you run — live *and* headless
|
||||
|
||||
One tool surface over **two engines**: drive a live AutoCAD session over COM, or
|
||||
run fully headless with ezdxf on Linux, macOS, or a CI runner with no AutoCAD
|
||||
installed. Most competitors are COM-only (Windows + a running AutoCAD) *or*
|
||||
file-only — not both behind an identical API with identical response shapes.
|
||||
|
||||
### 5. It is engineered like a product, not a demo
|
||||
|
||||
**360 passing tests**, a ruff-clean codebase, a security model that assumes
|
||||
hostile input (AutoLISP allowlist with bypass-regression tests, path-traversal
|
||||
guards, HTTP bind guard + bearer-token auth, per-call COM timeout), and a scalar
|
||||
**drawing-score + invalidity-ratio** so quality is a number you can track across
|
||||
releases — not an adjective. A reproducible correctness benchmark ships in
|
||||
[`benchmarks/`](benchmarks/).
|
||||
|
||||
> **In one line:** other AutoCAD MCPs help a model *draw*; AutoCAD MCP Pro helps
|
||||
> a model *deliver a drawing that passes engineering review* — validated,
|
||||
> toleranced, standards-compliant, on any OS, with the tests to prove it.
|
||||
|
||||
See the [feature comparison](#what-makes-it-different) at the top and the
|
||||
[Roadmap](#roadmap) for how the moat widens from here (closed-loop
|
||||
`critique → repair` refiner, ISO 286 fits, public benchmark numbers).
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Dual Engine Architecture**
|
||||
@@ -63,28 +161,29 @@ AutoCAD MCP Pro takes a different stance:
|
||||
- **ezdxf backend**: headless DXF file operations powered by [ezdxf](https://github.com/mozman/ezdxf). Works on every platform, ideal for batch workloads and CI pipelines.
|
||||
- Automatic backend selection, with a clean override via `AUTOCAD_MCP_BACKEND`.
|
||||
|
||||
- **111 Tools, 12 Categories**
|
||||
- **116 Tools, 14 Categories**
|
||||
- Drawing management — `drawing_new`, `drawing_open`, `drawing_save`, `drawing_save_as`, `drawing_export_dxf`, `drawing_export_pdf`, `drawing_purge`, `drawing_audit`, `drawing_undo`, `drawing_redo`, `drawing_close`
|
||||
- Entity creation — line, circle, arc, polyline, rectangle, text, mtext, hatch, spline, ellipse, point, block reference, batch create
|
||||
- Dimensions — linear, aligned, angular, radius, diameter
|
||||
- Entity modification — move, copy, rotate, scale, mirror, offset, delete, rectangular array, polar array, batch modify, set properties
|
||||
- Dimensions — linear, aligned, angular, radius, diameter (with ISO 129 `tol_mode`: ± / deviation / limit / basic + fit text)
|
||||
- Entity modification — move, copy, rotate, scale, mirror, offset, delete, rectangular/polar array, batch modify, set properties, plus in-place `entity_edit_text` and `entity_edit_geometry` (handle-preserving)
|
||||
- Entity query — `entity_get`, `entity_list`, `entity_delete_many`, and `selection_get` (read the user's live viewport "pickfirst" selection so the AI dimensions/edits only what was picked, not the whole drawing — COM backend)
|
||||
- Layer management — full lifecycle: create, delete, modify, freeze/thaw, lock/unlock, hide/show, isolate, set current
|
||||
- Block operations — list, insert, explode, attribute get/set, create-from-entities, find references
|
||||
- Analysis — entity stats, region select, distance/area measurement, bounding box, select by type/layer, layer statistics
|
||||
- View control — zoom extents, zoom window, screenshot, combined zoom-and-screenshot
|
||||
- Transactions — begin, commit, rollback, with disk-backed snapshots
|
||||
- System — status, get/set variables, run command, run AutoLISP, about
|
||||
- System — status, get/set variables, run command, run AutoLISP, about, plus `drawing_settings` (friendly units/scale/precision/osnap facade)
|
||||
- Templates and validation — apply standard layer templates, validate drawings against rule sets
|
||||
- Engineering / deterministic CAD — involute gear front view + section A-A, DIN 6885 keyed bore, ISO A3 title block, and the 8-step `drawing_finalize` gate
|
||||
- Premium drafting workflow — `drawing_plan`, deterministic OSNAP (`point_from_snap` / `point_intersection` / `point_tangent`), `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`, `drawing_critique`, `construction_*`
|
||||
- GD&T (ISO 1101 / ASME Y14.5) — `gd_frame` (feature control frames: all 14 characteristics, ⌀ zones, Ⓜ/Ⓛ/Ⓢ modifiers, multi-datum) and `datum_feature`, with a datum-consistency critique gate
|
||||
- Premium drafting workflow — `drawing_plan`, deterministic OSNAP (`point_from_snap` / `point_intersection` / `point_tangent`), `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`, `drawing_critique`, `construction_*`, and a scalar drawing-score + invalidity-ratio at finalize
|
||||
|
||||
- **Production-Grade Plumbing**
|
||||
- FastMCP 3.0 lifespan-managed backend singleton
|
||||
- Middleware stack: error handling, audit logging, timing, request logging
|
||||
- Structured progress reports for long-running operations (`drawing_open`, exports, batch ops)
|
||||
- 318 tests across drawing, entity, dimension, layer, block, analysis, batch/template, engineering, premium, mocked-COM, and security suites
|
||||
- Ruff-clean codebase, 318-test suite
|
||||
- 360 tests across drawing, entity, dimension, layer, block, analysis, batch/template, engineering, GD&T, premium, mocked-COM, and security suites
|
||||
- Ruff-clean codebase, 360-test suite
|
||||
|
||||
- **Closed-Loop Quality Gate**
|
||||
- `drawing_finalize` runs **both** the 8-step structural validator **and** the premium ISO-128 critique (lineweights, layer colors, untrimmed corners, duplicate entities, leftover construction, dimension overlap) — leftover scaffolding or a non-standard lineweight blocks the gate instead of slipping through.
|
||||
@@ -103,7 +202,7 @@ AutoCAD MCP Pro takes a different stance:
|
||||
|
||||
## AI Client Compatibility
|
||||
|
||||
AutoCAD MCP Pro implements the Model Context Protocol. **If a host can speak MCP, it can drive this server.** No model-specific code, no per-vendor patches — the contract is the tool schema, and any MCP-aware client (and any LLM behind it) inherits the full 87-tool surface for free.
|
||||
AutoCAD MCP Pro implements the Model Context Protocol. **If a host can speak MCP, it can drive this server.** No model-specific code, no per-vendor patches — the contract is the tool schema, and any MCP-aware client (and any LLM behind it) inherits the full 116-tool surface for free.
|
||||
|
||||
| Host | Status | Notes |
|
||||
|-----------------------------|-------------|------------------------------------|
|
||||
@@ -125,7 +224,7 @@ pip install -e ".[full]"
|
||||
python server.py
|
||||
```
|
||||
|
||||
That is it. The server starts in STDIO mode and your MCP client will discover all 111 tools.
|
||||
That is it. The server starts in STDIO mode and your MCP client will discover all 116 tools.
|
||||
|
||||
For headless / CI workflows:
|
||||
|
||||
@@ -242,18 +341,21 @@ Copy `.env.example` to `.env` and edit:
|
||||
|---------------------|------:|---------------------------------------------------------------------------|
|
||||
| Drawing | 11 | `drawing_new`, `drawing_open`, `drawing_save`, `drawing_export_pdf`, `drawing_purge`, `drawing_close` |
|
||||
| Entity Creation | 13 | `entity_create_line`, `entity_create_polyline`, `entity_create_hatch`, `entity_create_block_ref` |
|
||||
| Dimensions | 5 | `dimension_linear`, `dimension_aligned`, `dimension_angular`, `dimension_radius`, `dimension_diameter`|
|
||||
| Entity Modification | 10 | `entity_move`, `entity_rotate`, `entity_array_polar`, `entity_offset`, `entity_array_rectangular` |
|
||||
| Dimensions | 5 | `dimension_linear`, `dimension_aligned`, `dimension_angular`, `dimension_radius`, `dimension_diameter` (with ISO 129 `tol_mode`) |
|
||||
| Entity Modification | 12 | `entity_move`, `entity_rotate`, `entity_offset`, `entity_edit_text`, `entity_edit_geometry`, `entity_set_properties` |
|
||||
| Entity Query | 4 | `entity_get`, `entity_list`, `entity_delete_many`, `selection_get` |
|
||||
| Layer Management | 12 | `layer_create`, `layer_freeze`, `layer_isolate`, `layer_modify` |
|
||||
| Block Operations | 7 | `block_insert`, `block_explode`, `block_create_from_entities`, `block_find_references` |
|
||||
| Analysis | 8 | `analysis_entity_stats`, `analysis_bounding_box`, `analysis_select_by_layer`, `analysis_layer_stats` |
|
||||
| View | 4 | `view_zoom_extents`, `view_screenshot`, `view_zoom_and_screenshot` |
|
||||
| Transactions | 3 | `transaction_begin`, `transaction_commit`, `transaction_rollback` |
|
||||
| System | 6 | `system_status`, `system_run_command`, `system_run_lisp`, `system_about` |
|
||||
| System | 7 | `system_status`, `system_run_command`, `system_run_lisp`, `system_about`, `drawing_settings` |
|
||||
| Batch / Templates | 5 | `entity_batch_create`, `entity_batch_modify`, `template_apply_layers`, `validation_check` |
|
||||
| Engineering | 7 | `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize` |
|
||||
| Premium workflow | 11 | `drawing_plan`, `drawing_critique`, `point_from_snap` / `point_intersection` / `point_tangent`, `dimension_auto`, `entity_select_smart` |
|
||||
| GD&T (ISO 1101) | 2 | `gd_frame` (feature control frame), `datum_feature` |
|
||||
|
||||
`system_about` returns the live tool inventory — the count above is illustrative; the runtime number is authoritative.
|
||||
`system_about` returns the live tool inventory — the counts above are illustrative; the runtime number is authoritative.
|
||||
|
||||
---
|
||||
|
||||
@@ -396,16 +498,32 @@ autocad-mcp/
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] **1.0** — Initial public release
|
||||
- [x] **1.1** — Correctness, cross-backend parity & the enforced quality gate: `drawing_finalize` now runs the premium ISO-128 critique; deterministic geometry (`point_intersection`/`point_tangent`); dimension/save-format/polar-array/offset fixes across both engines; COM robustness (CoUninitialize, transaction & LISP guards); security hardening (HTTP bind guard on every launch path, LISP-allowlist bypass tests); mocked-COM test harness. **318 tests.**
|
||||
- [ ] **1.2** — Closed-loop validation moat: scalar drawing-score, pre-plan clarification pass, and an iterative critique→repair→re-critique refiner (with dedicated transaction-stack isolation)
|
||||
- [ ] **1.3** — ISO production: 2D GD&T (ISO 1101) feature-control frames, ISO 129 dimension tolerances/fits, ISO-25 dimension styles
|
||||
- [ ] **1.4** — Public benchmark suite (`benchmarks/` with reproducible runner)
|
||||
- [ ] **1.3** — Backend `capabilities()` map and per-tool enablement (e.g. hide `system_run_command` automatically on ezdxf)
|
||||
- [ ] **1.4** — Module split for `server.py` and a clean `services/` orchestration layer
|
||||
- [ ] **1.5** — Windows CI matrix with mocked COM backend; full coverage report
|
||||
- [ ] **2.0** — Multi-document context, xref-aware tools, layout/paper-space first-class support
|
||||
- [ ] **2.x** — Native Linux/macOS support for live control via [ODA File Converter](https://www.opendesign.com/guestfiles/oda_file_converter) for round-trip DWG
|
||||
Sequenced by dependency, not wishlist: each release builds on the one before it.
|
||||
The theme is **widen the standards-validation moat, then close the loop, then
|
||||
reach production-drawing parity** — and only then chase breadth.
|
||||
|
||||
### Shipped
|
||||
|
||||
- [x] **1.0 — Foundation.** Dual COM + ezdxf engine, FastMCP 3.0 middleware stack, the core drawing / entity / layer / block / dimension / analysis tool surface.
|
||||
- [x] **1.1 — Correctness & the enforced quality gate.** `drawing_finalize` runs the premium ISO-128 critique; deterministic geometry (`point_intersection` / `point_tangent`); dimension / save-format / polar-array / offset fixes across both engines; COM robustness (CoUninitialize, transaction & LISP guards); security hardening (HTTP bind guard, AutoLISP-allowlist bypass tests); mocked-COM harness. **318 tests.**
|
||||
- [x] **1.2 — ISO production + a measurable moat.** 2D GD&T (ISO 1101) feature control frames + datum features with a datum-consistency gate; ISO 129 dimension tolerances (±/deviation/limit/basic + text override); in-place `entity_edit_text` / `entity_edit_geometry`; the `drawing_settings` facade; scalar drawing-score + invalidity-ratio at finalize. **360 tests.**
|
||||
|
||||
### Next — own the closed loop (the #1 accuracy lever)
|
||||
|
||||
- [ ] **1.3 — Closed-loop refiner.** Iterative `critique → repair → re-critique` with dedicated transaction-stack isolation, plus a pre-plan clarification pass in `drawing_plan` that surfaces missing/conflicting dimensions before any geometry. *Depends on the 1.2 scalar score as its objective function.*
|
||||
- [ ] **1.4 — ISO production depth.** ISO 286 fit tables (`H7`/`g6` → resolved deviations feeding the 1.2 tolerance engine), a reusable ISO-25 dimension style, and MLEADER leader notes.
|
||||
|
||||
### Then — prove it & harden it
|
||||
|
||||
- [ ] **1.5 — Public benchmark suite.** A reproducible one-command runner in `benchmarks/` (correctness + throughput by backend and model) — turns the "numbers, not adjectives" promise into CI-tracked figures, anchored on the 1.2 drawing-score.
|
||||
- [ ] **1.6 — Architecture & capability introspection.** A backend `capabilities()` map with per-tool enablement (e.g. auto-hide `system_run_command` on ezdxf), plus a `server.py` module split into a clean `services/` orchestration layer.
|
||||
- [ ] **1.7 — CI & coverage.** Windows CI matrix exercising the mocked COM backend on every push, with a coverage gate.
|
||||
|
||||
### Later — breadth (table-stakes elsewhere, deliberately deferred)
|
||||
|
||||
- [ ] **2.0 — Multi-document & paper space.** Multi-drawing context, xref-aware tools, and first-class layout / paper-space + plot configuration.
|
||||
- [ ] **2.1 — Optional 3D solids tier (COM-only).** Extrude / revolve / boolean on the live COM backend; ezdxf intentionally stays 2D (no ACIS kernel), so this ships behind a capability flag rather than breaking dual-backend parity.
|
||||
- [ ] **2.2 — Native Linux/macOS live control.** Round-trip DWG via the [ODA File Converter](https://www.opendesign.com/guestfiles/oda_file_converter) so headless hosts get true DWG in/out.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -244,6 +244,10 @@ class AutoCADBackend(ABC):
|
||||
self, x1: float, y1: float, x2: float, y2: float,
|
||||
dim_x: float, dim_y: float, rotation: float = 0.0,
|
||||
layer: str | None = None,
|
||||
tol_upper: float | None = None,
|
||||
tol_lower: float | None = None,
|
||||
tol_mode: str = "none",
|
||||
text_override: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
@@ -265,12 +269,20 @@ class AutoCADBackend(ABC):
|
||||
async def dimension_radius(
|
||||
self, cx: float, cy: float, chord_x: float, chord_y: float,
|
||||
leader_length: float = 10.0, layer: str | None = None,
|
||||
tol_upper: float | None = None,
|
||||
tol_lower: float | None = None,
|
||||
tol_mode: str = "none",
|
||||
text_override: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def dimension_diameter(
|
||||
self, x1: float, y1: float, x2: float, y2: float,
|
||||
leader_length: float = 10.0, layer: str | None = None,
|
||||
tol_upper: float | None = None,
|
||||
tol_lower: float | None = None,
|
||||
tol_mode: str = "none",
|
||||
text_override: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
# ── entity modification ──────────────────────────────────────────────────
|
||||
@@ -372,6 +384,34 @@ class AutoCADBackend(ABC):
|
||||
visible: bool | None = None,
|
||||
) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_edit_text(
|
||||
self, handle: str,
|
||||
text: str | None = None,
|
||||
height: float | None = None,
|
||||
rotation: float | None = None,
|
||||
) -> EntityInfo:
|
||||
"""Edit an existing TEXT or MTEXT entity in place. Any argument left
|
||||
None is unchanged. Returns the updated EntityInfo. Raises if the handle
|
||||
is not a TEXT/MTEXT entity."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_edit_geometry(
|
||||
self, handle: str,
|
||||
cx: float | None = None, cy: float | None = None,
|
||||
radius: float | None = None,
|
||||
x1: float | None = None, y1: float | None = None,
|
||||
x2: float | None = None, y2: float | None = None,
|
||||
start_angle: float | None = None, end_angle: float | None = None,
|
||||
) -> EntityInfo:
|
||||
"""Edit the defining geometry of an existing entity in place.
|
||||
- CIRCLE: cx / cy / radius
|
||||
- LINE: x1 / y1 (start), x2 / y2 (end)
|
||||
- ARC: cx / cy / radius / start_angle / end_angle (degrees)
|
||||
Any argument left None is unchanged. Raises for unsupported types."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_list(
|
||||
self,
|
||||
@@ -1159,6 +1199,180 @@ class AutoCADBackend(ABC):
|
||||
|
||||
return [e for e in ents if _ok(e)]
|
||||
|
||||
# ── GD&T (ISO 1101 / ASME Y14.5) ─────────────────────────────────────────
|
||||
# Composed from LINE + TEXT primitives so the exact same frame renders on
|
||||
# both COM and ezdxf (ezdxf's native TOLERANCE entity renders blank through
|
||||
# the matplotlib frontend). Deterministic layout math lives in engineering/
|
||||
# gdt.py; datum letters are tracked so the `gdt` critique focus can verify
|
||||
# every referenced datum is actually established on the part.
|
||||
|
||||
async def _place_boxed_text(
|
||||
self, text: str, cx: float, cy: float, text_h: float, layer: str,
|
||||
) -> str:
|
||||
"""Create a TEXT entity roughly centred on (cx, cy). Returns its handle.
|
||||
entity_create_text has no alignment arg, so approximate the centring with
|
||||
the same width heuristic the title block uses."""
|
||||
approx_w = len(text) * text_h * 0.7
|
||||
t = await self.entity_create_text(
|
||||
text, cx - approx_w / 2.0, cy - text_h / 2.0, height=text_h, layer=layer,
|
||||
)
|
||||
return t.handle
|
||||
|
||||
async def draw_feature_control_frame(
|
||||
self,
|
||||
symbol: str,
|
||||
tolerance: str | float,
|
||||
x: float,
|
||||
y: float,
|
||||
datums: list[str] | None = None,
|
||||
height: float = 5.0,
|
||||
diameter: bool = False,
|
||||
modifier: str | None = None,
|
||||
layer: str | None = None,
|
||||
) -> dict:
|
||||
"""Draw an ISO 1101 feature control frame with bottom-left corner at (x, y).
|
||||
|
||||
symbol: one of the 14 geometric characteristics (straightness, flatness,
|
||||
circularity, cylindricity, profile_line, profile_surface, angularity,
|
||||
perpendicularity, parallelism, position, concentricity, symmetry,
|
||||
circular_runout, total_runout). `datums` is the ordered datum reference
|
||||
list (e.g. ["A", "B"]); `diameter=True` prefixes ⌀ for a cylindrical zone;
|
||||
`modifier` ∈ {M, L, S} appends the material-condition symbol.
|
||||
|
||||
Orientation/location/runout characteristics require at least one datum
|
||||
(raises otherwise). The referenced datums are recorded for the `gdt`
|
||||
critique focus.
|
||||
"""
|
||||
from engineering.gdt import fcf_compartments, fcf_layout
|
||||
comps = fcf_compartments(
|
||||
symbol, tolerance, datums, diameter=diameter, modifier=modifier,
|
||||
)
|
||||
layer = layer or self._role_layer("dim")
|
||||
await self._ensure_layer(layer, color=2)
|
||||
lay = fcf_layout(comps, float(x), float(y), float(height))
|
||||
handles: list[str] = []
|
||||
|
||||
box = lay["box"]
|
||||
box_poly = await self.entity_create_polyline(
|
||||
[[bx, by] for bx, by in box], closed=False, layer=layer,
|
||||
)
|
||||
handles.append(box_poly.handle)
|
||||
for (sx, sy), (ex, ey) in lay["dividers"]:
|
||||
ln = await self.entity_create_line(sx, sy, ex, ey, layer=layer)
|
||||
handles.append(ln.handle)
|
||||
th = lay["text_height"]
|
||||
for text, tcx, tcy in lay["labels"]:
|
||||
handles.append(await self._place_boxed_text(text, tcx, tcy, th, layer))
|
||||
|
||||
refs = getattr(self, "_gdt_datums_referenced", None)
|
||||
if refs is None:
|
||||
refs = set()
|
||||
self._gdt_datums_referenced = refs
|
||||
for d in (datums or []):
|
||||
d = str(d).strip().upper()
|
||||
if d:
|
||||
refs.add(d)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"symbol": symbol,
|
||||
"compartments": comps,
|
||||
"handles": handles,
|
||||
"width": lay["width"],
|
||||
"height": lay["height"],
|
||||
"layer": layer,
|
||||
}
|
||||
|
||||
async def draw_datum_feature(
|
||||
self,
|
||||
letter: str,
|
||||
x: float,
|
||||
y: float,
|
||||
size: float = 5.0,
|
||||
layer: str | None = None,
|
||||
) -> dict:
|
||||
"""Place an ISO 1101 datum feature symbol (filled triangle + boxed letter)
|
||||
with its apex at (x, y). Records the datum letter so a feature control
|
||||
frame that references it passes the `gdt` critique focus."""
|
||||
from engineering.gdt import datum_triangle, fcf_layout
|
||||
letter = str(letter).strip().upper()
|
||||
if not letter:
|
||||
raise RuntimeError("draw_datum_feature: a datum letter is required.")
|
||||
layer = layer or self._role_layer("dim")
|
||||
await self._ensure_layer(layer, color=2)
|
||||
size = float(size)
|
||||
handles: list[str] = []
|
||||
|
||||
tri = datum_triangle(float(x), float(y), size, down=True)
|
||||
tri_poly = await self.entity_create_polyline(
|
||||
[[px, py] for px, py in tri], closed=True, layer=layer,
|
||||
)
|
||||
handles.append(tri_poly.handle)
|
||||
|
||||
box_h = size * 1.4
|
||||
lay = fcf_layout([letter], float(x) - box_h / 2.0, float(y) - size - box_h, box_h)
|
||||
box_poly = await self.entity_create_polyline(
|
||||
[[bx, by] for bx, by in lay["box"]], closed=False, layer=layer,
|
||||
)
|
||||
handles.append(box_poly.handle)
|
||||
for text, tcx, tcy in lay["labels"]:
|
||||
handles.append(
|
||||
await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer)
|
||||
)
|
||||
|
||||
defined = getattr(self, "_gdt_datums_defined", None)
|
||||
if defined is None:
|
||||
defined = set()
|
||||
self._gdt_datums_defined = defined
|
||||
defined.add(letter)
|
||||
|
||||
return {"ok": True, "datum": letter, "handles": handles, "layer": layer}
|
||||
|
||||
# ── drawing settings (friendly system-variable facade) ───────────────────
|
||||
# A user-facing wrapper over system_get_variable / system_set_variable that
|
||||
# maps memorable names ("units", "dimscale", …) to AutoCAD system variables.
|
||||
# Concrete + backend-agnostic: both engines accept the bare sysvar name, so
|
||||
# the same call reads/writes on live COM and headless ezdxf alike.
|
||||
|
||||
async def drawing_settings(self, settings: dict | None = None) -> dict:
|
||||
"""Read (no args) or apply (with args) common drawing settings.
|
||||
|
||||
With ``settings=None`` returns a snapshot of every known setting. With a
|
||||
dict, applies each provided key and returns ``{applied, errors, current}``.
|
||||
Friendly keys: units, linear_precision, angular_precision, ltscale,
|
||||
dimscale, text_size, point_mode, point_size, osmode, fillet_radius.
|
||||
`units` accepts mm/cm/m/inch/feet (mapped to the INSUNITS code)."""
|
||||
if not settings:
|
||||
snapshot: dict = {}
|
||||
for key, (var, kind) in _SETTING_MAP.items():
|
||||
try:
|
||||
raw = await self.system_get_variable(var)
|
||||
except Exception as exc:
|
||||
snapshot[key] = {"error": str(exc)}
|
||||
continue
|
||||
snapshot[key] = _decode_setting(key, kind, raw)
|
||||
return {"ok": True, "settings": snapshot}
|
||||
|
||||
applied: dict = {}
|
||||
errors: dict = {}
|
||||
for key, value in settings.items():
|
||||
spec = _SETTING_MAP.get(key)
|
||||
if spec is None:
|
||||
errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})"
|
||||
continue
|
||||
var, kind = spec
|
||||
try:
|
||||
encoded = _encode_setting(key, kind, value)
|
||||
await self.system_set_variable(var, encoded)
|
||||
applied[key] = value
|
||||
except Exception as exc:
|
||||
errors[key] = str(exc)
|
||||
|
||||
result = {"ok": not errors, "applied": applied}
|
||||
if errors:
|
||||
result["errors"] = errors
|
||||
return result
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1166,6 +1380,73 @@ class AutoCADBackend(ABC):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Friendly setting name -> (AutoCAD system variable, value kind).
|
||||
_SETTING_MAP: dict[str, tuple[str, str]] = {
|
||||
"units": ("INSUNITS", "units"),
|
||||
"linear_precision": ("LUPREC", "int"),
|
||||
"angular_precision": ("AUPREC", "int"),
|
||||
"ltscale": ("LTSCALE", "float"),
|
||||
"dimscale": ("DIMSCALE", "float"),
|
||||
"text_size": ("TEXTSIZE", "float"),
|
||||
"point_mode": ("PDMODE", "int"),
|
||||
"point_size": ("PDSIZE", "float"),
|
||||
"osmode": ("OSMODE", "int"),
|
||||
"fillet_radius": ("FILLETRAD", "float"),
|
||||
}
|
||||
|
||||
# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code.
|
||||
_UNIT_TO_CODE: dict[str, int] = {
|
||||
"unitless": 0, "inch": 1, "inches": 1, "in": 1,
|
||||
"feet": 2, "ft": 2, "foot": 2,
|
||||
"mm": 4, "millimeter": 4, "millimeters": 4,
|
||||
"cm": 5, "centimeter": 5, "centimeters": 5,
|
||||
"m": 6, "meter": 6, "meters": 6,
|
||||
}
|
||||
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
|
||||
|
||||
|
||||
def _encode_setting(key: str, kind: str, value: Any) -> Any:
|
||||
"""Coerce a friendly value into the raw system-variable value."""
|
||||
if kind == "units":
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return int(value)
|
||||
code = _UNIT_TO_CODE.get(str(value).strip().lower())
|
||||
if code is None:
|
||||
raise ValueError(
|
||||
f"units: unknown value {value!r}. Use one of "
|
||||
f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
|
||||
)
|
||||
return code
|
||||
if kind == "int":
|
||||
return int(float(value))
|
||||
if kind == "float":
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
|
||||
def _decode_setting(key: str, kind: str, raw: Any) -> Any:
|
||||
"""Present a raw system-variable value in friendly form."""
|
||||
if raw is None:
|
||||
return None
|
||||
if kind == "units":
|
||||
try:
|
||||
code = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
|
||||
if kind == "int":
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
if kind == "float":
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
return raw
|
||||
|
||||
|
||||
def shoelace_area(points: list[list[float]]) -> float:
|
||||
"""Calculate polygon area using the shoelace formula."""
|
||||
n = len(points)
|
||||
|
||||
+120
-1
@@ -202,6 +202,46 @@ def _regen():
|
||||
log.debug("Regen failed: %s", exc)
|
||||
|
||||
|
||||
def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_override=None):
|
||||
"""Best-effort ISO 129 tolerance rendering on a live COM dimension.
|
||||
|
||||
Maps the shared tolerance spec (see engineering/tolerances.py) to the COM
|
||||
Dimension object's per-entity properties (ToleranceDisplay / ToleranceUpper
|
||||
/ ToleranceLower, or the Limits display) plus an optional TextOverride. COM
|
||||
property names vary by AutoCAD version, so each set is guarded — a missing
|
||||
property degrades gracefully instead of failing the dimension.
|
||||
"""
|
||||
from engineering.tolerances import build_dim_override
|
||||
try:
|
||||
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
|
||||
except ValueError:
|
||||
raise
|
||||
if text:
|
||||
try:
|
||||
dim.TextOverride = text
|
||||
except Exception as exc:
|
||||
log.debug("dim TextOverride failed: %s", exc)
|
||||
if not override:
|
||||
return
|
||||
# acTolerance* enums: 0=None, 1=Symmetrical, 2=Deviation, 3=Limits, 4=Basic
|
||||
try:
|
||||
if "dimgap" in override: # basic (boxed) dimension
|
||||
dim.ToleranceDisplay = 4
|
||||
return
|
||||
if override.get("dimlim"): # limit dimension
|
||||
dim.ToleranceDisplay = 3
|
||||
elif override.get("dimtp") == override.get("dimtm"):
|
||||
dim.ToleranceDisplay = 1 # symmetrical ±
|
||||
else:
|
||||
dim.ToleranceDisplay = 2 # deviation
|
||||
if "dimtp" in override:
|
||||
dim.ToleranceUpperLimit = float(override["dimtp"])
|
||||
if "dimtm" in override:
|
||||
dim.ToleranceLowerLimit = float(override["dimtm"])
|
||||
except Exception as exc:
|
||||
log.debug("dim tolerance apply failed (version-dependent COM props): %s", exc)
|
||||
|
||||
|
||||
def _entity_info(entity) -> EntityInfo:
|
||||
"""Convert a COM entity object to EntityInfo dataclass."""
|
||||
try:
|
||||
@@ -780,6 +820,7 @@ class ComBackend(AutoCADBackend):
|
||||
|
||||
async def dimension_linear(
|
||||
self, x1, y1, x2, y2, dim_x, dim_y, rotation=0.0, layer=None,
|
||||
tol_upper=None, tol_lower=None, tol_mode="none", text_override=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
mspace = _msp()
|
||||
@@ -789,6 +830,7 @@ class ComBackend(AutoCADBackend):
|
||||
)
|
||||
if layer:
|
||||
dim.Layer = layer
|
||||
_apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode, text_override)
|
||||
_regen()
|
||||
return _entity_info(dim)
|
||||
return await self._run(_sync)
|
||||
@@ -825,6 +867,7 @@ class ComBackend(AutoCADBackend):
|
||||
|
||||
async def dimension_radius(
|
||||
self, cx, cy, chord_x, chord_y, leader_length=10.0, layer=None,
|
||||
tol_upper=None, tol_lower=None, tol_mode="none", text_override=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
mspace = _msp()
|
||||
@@ -833,12 +876,14 @@ class ComBackend(AutoCADBackend):
|
||||
)
|
||||
if layer:
|
||||
dim.Layer = layer
|
||||
_apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode, text_override)
|
||||
_regen()
|
||||
return _entity_info(dim)
|
||||
return await self._run(_sync)
|
||||
|
||||
async def dimension_diameter(
|
||||
self, x1, y1, x2, y2, leader_length=10.0, layer=None,
|
||||
tol_upper=None, tol_lower=None, tol_mode="none", text_override=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
mspace = _msp()
|
||||
@@ -847,6 +892,7 @@ class ComBackend(AutoCADBackend):
|
||||
)
|
||||
if layer:
|
||||
dim.Layer = layer
|
||||
_apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode, text_override)
|
||||
_regen()
|
||||
return _entity_info(dim)
|
||||
return await self._run(_sync)
|
||||
@@ -1036,6 +1082,75 @@ class ComBackend(AutoCADBackend):
|
||||
return {"ok": True, "handle": handle}
|
||||
return await self._run(_sync)
|
||||
|
||||
async def entity_edit_text(
|
||||
self, handle, text=None, height=None, rotation=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
ent = doc.HandleToObject(handle)
|
||||
name = ent.ObjectName.replace("AcDb", "").upper()
|
||||
if name not in ("TEXT", "MTEXT"):
|
||||
raise RuntimeError(
|
||||
f"entity_edit_text: handle {handle} is {name}, expected TEXT or MTEXT."
|
||||
)
|
||||
if text is not None:
|
||||
ent.TextString = str(text)
|
||||
if height is not None:
|
||||
ent.Height = float(height)
|
||||
if rotation is not None:
|
||||
# MTEXT uses radians for Rotation; DBText/Text uses radians too.
|
||||
ent.Rotation = deg2rad(float(rotation))
|
||||
_regen()
|
||||
return _entity_info(ent)
|
||||
return await self._run(_sync)
|
||||
|
||||
async def entity_edit_geometry(
|
||||
self, handle, cx=None, cy=None, radius=None,
|
||||
x1=None, y1=None, x2=None, y2=None,
|
||||
start_angle=None, end_angle=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
ent = doc.HandleToObject(handle)
|
||||
name = ent.ObjectName.replace("AcDb", "").upper()
|
||||
if name == "CIRCLE":
|
||||
c = ent.Center
|
||||
ent.Center = _apoint(
|
||||
float(cx) if cx is not None else c[0],
|
||||
float(cy) if cy is not None else c[1],
|
||||
)
|
||||
if radius is not None:
|
||||
ent.Radius = float(radius)
|
||||
elif name == "LINE":
|
||||
s, e = ent.StartPoint, ent.EndPoint
|
||||
ent.StartPoint = _apoint(
|
||||
float(x1) if x1 is not None else s[0],
|
||||
float(y1) if y1 is not None else s[1],
|
||||
)
|
||||
ent.EndPoint = _apoint(
|
||||
float(x2) if x2 is not None else e[0],
|
||||
float(y2) if y2 is not None else e[1],
|
||||
)
|
||||
elif name == "ARC":
|
||||
c = ent.Center
|
||||
ent.Center = _apoint(
|
||||
float(cx) if cx is not None else c[0],
|
||||
float(cy) if cy is not None else c[1],
|
||||
)
|
||||
if radius is not None:
|
||||
ent.Radius = float(radius)
|
||||
if start_angle is not None:
|
||||
ent.StartAngle = deg2rad(float(start_angle))
|
||||
if end_angle is not None:
|
||||
ent.EndAngle = deg2rad(float(end_angle))
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"entity_edit_geometry: {name} not supported (use CIRCLE, LINE, or ARC)."
|
||||
)
|
||||
_regen()
|
||||
return _entity_info(ent)
|
||||
return await self._run(_sync)
|
||||
|
||||
async def entity_list(
|
||||
self, type_filter=None, layer_filter=None, limit=200, offset=0,
|
||||
) -> list[EntityInfo]:
|
||||
@@ -1582,9 +1697,13 @@ class ComBackend(AutoCADBackend):
|
||||
"open_documents": doc_count,
|
||||
"active_document": active_doc,
|
||||
"transaction_active": tx_active,
|
||||
# Accurate capability list: the backend authors 2D entities
|
||||
# (lines/arcs/circles/polylines/text/hatch/dims/blocks). It
|
||||
# does NOT create 3D solids, so the old "all_entity_types"
|
||||
# claim was false — report "entities_2d" instead.
|
||||
"capabilities": [
|
||||
"live_control", "screenshot", "transactions",
|
||||
"com_api", "lisp_execution", "all_entity_types",
|
||||
"com_api", "lisp_execution", "entities_2d",
|
||||
],
|
||||
}
|
||||
except Exception as exc:
|
||||
|
||||
@@ -722,14 +722,19 @@ class EzdxfBackend(AutoCADBackend):
|
||||
|
||||
async def dimension_linear(
|
||||
self, x1, y1, x2, y2, dim_x, dim_y, rotation=0.0, layer=None,
|
||||
tol_upper=None, tol_lower=None, tol_mode="none", text_override=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
from engineering.tolerances import build_dim_override
|
||||
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
|
||||
msp = self._msp()
|
||||
dim = msp.add_linear_dim(
|
||||
base=(float(dim_x), float(dim_y)),
|
||||
p1=(float(x1), float(y1)),
|
||||
p2=(float(x2), float(y2)),
|
||||
angle=float(rotation),
|
||||
text=text if text is not None else "<>",
|
||||
override=override or None,
|
||||
)
|
||||
dim.render()
|
||||
ent = dim.dimension
|
||||
@@ -779,8 +784,11 @@ class EzdxfBackend(AutoCADBackend):
|
||||
|
||||
async def dimension_radius(
|
||||
self, cx, cy, chord_x, chord_y, leader_length=10.0, layer=None,
|
||||
tol_upper=None, tol_lower=None, tol_mode="none", text_override=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
from engineering.tolerances import build_dim_override
|
||||
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
|
||||
msp = self._msp()
|
||||
cxf, cyf = float(cx), float(cy)
|
||||
radius = math.sqrt((chord_x - cxf) ** 2 + (chord_y - cyf) ** 2)
|
||||
@@ -793,6 +801,8 @@ class EzdxfBackend(AutoCADBackend):
|
||||
dim = msp.add_radius_dim(
|
||||
center=(cxf, cyf),
|
||||
mpoint=mpoint,
|
||||
text=text if text is not None else "<>",
|
||||
override=override or None,
|
||||
)
|
||||
dim.render()
|
||||
ent = dim.dimension
|
||||
@@ -804,8 +814,11 @@ class EzdxfBackend(AutoCADBackend):
|
||||
|
||||
async def dimension_diameter(
|
||||
self, x1, y1, x2, y2, leader_length=10.0, layer=None,
|
||||
tol_upper=None, tol_lower=None, tol_mode="none", text_override=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
from engineering.tolerances import build_dim_override
|
||||
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
|
||||
msp = self._msp()
|
||||
x1f, y1f, x2f, y2f = float(x1), float(y1), float(x2), float(y2)
|
||||
cx = (x1f + x2f) / 2
|
||||
@@ -820,6 +833,8 @@ class EzdxfBackend(AutoCADBackend):
|
||||
dim = msp.add_diameter_dim(
|
||||
center=(cx, cy),
|
||||
mpoint=mpoint,
|
||||
text=text if text is not None else "<>",
|
||||
override=override or None,
|
||||
)
|
||||
dim.render()
|
||||
ent = dim.dimension
|
||||
@@ -1061,6 +1076,84 @@ class EzdxfBackend(AutoCADBackend):
|
||||
return {"ok": True, "handle": handle}
|
||||
return await self._async(_sync)
|
||||
|
||||
async def entity_edit_text(
|
||||
self, handle, text=None, height=None, rotation=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
ent = self._get_entity(handle)
|
||||
et = ent.dxftype()
|
||||
if et == "TEXT":
|
||||
if text is not None:
|
||||
ent.dxf.text = str(text)
|
||||
if height is not None:
|
||||
ent.dxf.height = float(height)
|
||||
if rotation is not None:
|
||||
ent.dxf.rotation = float(rotation)
|
||||
elif et == "MTEXT":
|
||||
if text is not None:
|
||||
ent.text = str(text)
|
||||
if height is not None:
|
||||
ent.dxf.char_height = float(height)
|
||||
if rotation is not None:
|
||||
ent.dxf.rotation = float(rotation)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"entity_edit_text: handle {handle} is {et}, expected TEXT or MTEXT."
|
||||
)
|
||||
self._mark_dirty()
|
||||
return _entity_info_dxf(ent)
|
||||
return await self._async(_sync)
|
||||
|
||||
async def entity_edit_geometry(
|
||||
self, handle, cx=None, cy=None, radius=None,
|
||||
x1=None, y1=None, x2=None, y2=None,
|
||||
start_angle=None, end_angle=None,
|
||||
) -> EntityInfo:
|
||||
def _sync():
|
||||
ent = self._get_entity(handle)
|
||||
et = ent.dxftype()
|
||||
if et == "CIRCLE":
|
||||
c = ent.dxf.center
|
||||
ent.dxf.center = (
|
||||
float(cx) if cx is not None else c[0],
|
||||
float(cy) if cy is not None else c[1],
|
||||
c[2] if len(c) > 2 else 0.0,
|
||||
)
|
||||
if radius is not None:
|
||||
ent.dxf.radius = float(radius)
|
||||
elif et == "LINE":
|
||||
s, e = ent.dxf.start, ent.dxf.end
|
||||
ent.dxf.start = (
|
||||
float(x1) if x1 is not None else s[0],
|
||||
float(y1) if y1 is not None else s[1],
|
||||
s[2] if len(s) > 2 else 0.0,
|
||||
)
|
||||
ent.dxf.end = (
|
||||
float(x2) if x2 is not None else e[0],
|
||||
float(y2) if y2 is not None else e[1],
|
||||
e[2] if len(e) > 2 else 0.0,
|
||||
)
|
||||
elif et == "ARC":
|
||||
c = ent.dxf.center
|
||||
ent.dxf.center = (
|
||||
float(cx) if cx is not None else c[0],
|
||||
float(cy) if cy is not None else c[1],
|
||||
c[2] if len(c) > 2 else 0.0,
|
||||
)
|
||||
if radius is not None:
|
||||
ent.dxf.radius = float(radius)
|
||||
if start_angle is not None:
|
||||
ent.dxf.start_angle = float(start_angle)
|
||||
if end_angle is not None:
|
||||
ent.dxf.end_angle = float(end_angle)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"entity_edit_geometry: {et} not supported (use CIRCLE, LINE, or ARC)."
|
||||
)
|
||||
self._mark_dirty()
|
||||
return _entity_info_dxf(ent)
|
||||
return await self._async(_sync)
|
||||
|
||||
async def entity_list(
|
||||
self, type_filter=None, layer_filter=None, limit=200, offset=0,
|
||||
) -> list[EntityInfo]:
|
||||
|
||||
@@ -252,6 +252,36 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
|
||||
return issues
|
||||
|
||||
|
||||
async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
|
||||
"""Every datum referenced by a feature control frame must have a matching
|
||||
datum feature symbol.
|
||||
|
||||
ISO 1101 / ASME Y14.5: a position/orientation/runout FCF that cites datum A
|
||||
is meaningless unless datum A is actually established on the part. The base
|
||||
backend records referenced/defined datum letters as `draw_feature_control_frame`
|
||||
and `draw_datum_feature` run, so this check needs no geometry parsing and is
|
||||
identical on both engines.
|
||||
"""
|
||||
referenced = set(getattr(backend, "_gdt_datums_referenced", None) or set())
|
||||
defined = set(getattr(backend, "_gdt_datums_defined", None) or set())
|
||||
missing = sorted(referenced - defined)
|
||||
if not missing:
|
||||
return []
|
||||
return [Issue(
|
||||
severity="error",
|
||||
focus="gdt",
|
||||
message=(
|
||||
f"Feature control frame(s) reference datum(s) {missing} with no "
|
||||
"matching datum feature. Add draw_datum_feature for each datum."
|
||||
),
|
||||
detail={
|
||||
"missing_datums": missing,
|
||||
"defined": sorted(defined),
|
||||
"referenced": sorted(referenced),
|
||||
},
|
||||
)]
|
||||
|
||||
|
||||
_FOCUS_DISPATCH = {
|
||||
"iso128": _check_iso128,
|
||||
"layer_color": _check_layer_color,
|
||||
@@ -259,6 +289,7 @@ _FOCUS_DISPATCH = {
|
||||
"untrimmed_corner": _check_untrimmed_corner,
|
||||
"duplicate_entities": _check_duplicate_entities,
|
||||
"construction_left": _check_construction_left,
|
||||
"gdt": _check_gdt,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""2D GD&T (ISO 1101 / ASME Y14.5) feature control frames + datum features.
|
||||
|
||||
This is the single biggest "no competitor has this" capability: no surveyed
|
||||
text-to-CAD product or competing CAD MCP ships 2D geometric-tolerance authoring
|
||||
*with validation*. Frames are composed from plain LINE + TEXT primitives (not
|
||||
ezdxf's native TOLERANCE entity, which renders blank through the matplotlib
|
||||
frontend), so the exact same output lands on both the COM and ezdxf backends.
|
||||
|
||||
The layout math here is pure/deterministic and unit-tested without a backend;
|
||||
the async drawing orchestration lives on `AutoCADBackend` (base.py) so both
|
||||
engines inherit identical behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ISO 1101 geometric characteristic symbols (Unicode glyph per characteristic).
|
||||
# ASCII keys keep the tool schema legible; the glyph is what lands in the DXF.
|
||||
GEOMETRIC_SYMBOLS: dict[str, str] = {
|
||||
"straightness": "\u23e4", # ⏤
|
||||
"flatness": "\u23e5", # ⏥
|
||||
"circularity": "\u25cb", # ○
|
||||
"cylindricity": "\u232d", # ⌭
|
||||
"profile_line": "\u2312", # ⌒ profile of a line
|
||||
"profile_surface": "\u2313", # ⌓ profile of a surface
|
||||
"angularity": "\u2220", # ∠
|
||||
"perpendicularity": "\u27c2", # ⟂
|
||||
"parallelism": "\u2225", # ∥
|
||||
"position": "\u2316", # ⌖
|
||||
"concentricity": "\u25ce", # ◎
|
||||
"symmetry": "\u232f", # ⌯
|
||||
"circular_runout": "\u2197", # ↗
|
||||
"total_runout": "\u2913", # ⤓ (double-arrow stand-in for total runout)
|
||||
}
|
||||
|
||||
# Which characteristics are legal *without* a datum reference (form tolerances).
|
||||
# Everything else (orientation / location / runout) requires at least one datum.
|
||||
FORM_TOLERANCES: frozenset[str] = frozenset(
|
||||
{"straightness", "flatness", "circularity", "cylindricity", "profile_line", "profile_surface"}
|
||||
)
|
||||
|
||||
# Material-condition modifiers (ASME Y14.5): circled M / L / S.
|
||||
MATERIAL_MODIFIERS: dict[str, str] = {
|
||||
"M": "\u24c2", # Ⓜ maximum material condition
|
||||
"L": "\u24c1", # Ⓛ least material condition
|
||||
"S": "\u24c8", # Ⓢ regardless of feature size
|
||||
}
|
||||
|
||||
# Diameter prefix for cylindrical tolerance zones (true position of a hole, …).
|
||||
DIAMETER_PREFIX = "\u2300" # ⌀
|
||||
|
||||
|
||||
def _tolerance_text(tolerance: str | float, diameter: bool, modifier: str | None) -> str:
|
||||
"""Build the tolerance compartment string: optional ⌀, value, optional Ⓜ/Ⓛ/Ⓢ."""
|
||||
if isinstance(tolerance, (int, float)):
|
||||
# Trim a trailing ".0" so 0.10 -> "0.1", 5.0 -> "5".
|
||||
tol = f"{float(tolerance):g}"
|
||||
else:
|
||||
tol = str(tolerance).strip()
|
||||
parts = ""
|
||||
if diameter:
|
||||
parts += DIAMETER_PREFIX
|
||||
parts += tol
|
||||
if modifier:
|
||||
mod = modifier.strip().upper()
|
||||
if mod not in MATERIAL_MODIFIERS:
|
||||
raise ValueError(
|
||||
f"gdt: unknown material modifier {modifier!r}. "
|
||||
f"Use one of {sorted(MATERIAL_MODIFIERS)}."
|
||||
)
|
||||
parts += MATERIAL_MODIFIERS[mod]
|
||||
return parts
|
||||
|
||||
|
||||
def fcf_compartments(
|
||||
symbol: str,
|
||||
tolerance: str | float,
|
||||
datums: list[str] | None = None,
|
||||
*,
|
||||
diameter: bool = False,
|
||||
modifier: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return the ordered compartment strings of a feature control frame.
|
||||
|
||||
``[symbol_glyph, tolerance, datum1, datum2, …]``. Raises ValueError for an
|
||||
unknown characteristic, or for an orientation/location/runout characteristic
|
||||
given no datum (that combination is meaningless per ISO 1101).
|
||||
"""
|
||||
key = symbol.strip().lower()
|
||||
if key not in GEOMETRIC_SYMBOLS:
|
||||
raise ValueError(
|
||||
f"gdt: unknown characteristic {symbol!r}. Use one of {sorted(GEOMETRIC_SYMBOLS)}."
|
||||
)
|
||||
datums = [d.strip().upper() for d in (datums or []) if str(d).strip()]
|
||||
if key not in FORM_TOLERANCES and not datums:
|
||||
raise ValueError(
|
||||
f"gdt: characteristic '{key}' is an orientation/location/runout "
|
||||
"control and requires at least one datum reference."
|
||||
)
|
||||
comps = [GEOMETRIC_SYMBOLS[key], _tolerance_text(tolerance, diameter, modifier)]
|
||||
comps.extend(datums)
|
||||
return comps
|
||||
|
||||
|
||||
def fcf_layout(
|
||||
compartments: list[str],
|
||||
origin_x: float,
|
||||
origin_y: float,
|
||||
height: float,
|
||||
) -> dict:
|
||||
"""Deterministic geometry for a feature-control frame.
|
||||
|
||||
Given the compartment strings and a bottom-left origin, returns::
|
||||
|
||||
{
|
||||
"box": [(x, y), …, (x0, y0)], # closed outer rectangle
|
||||
"dividers": [((x, y0), (x, y1)), …],# internal vertical separators
|
||||
"labels": [(text, cx, cy), …], # centred text per compartment
|
||||
"width": float, "height": float,
|
||||
}
|
||||
|
||||
Widths: symbol + datum compartments are square (``height`` wide); the
|
||||
tolerance compartment grows with its text so it never clips.
|
||||
"""
|
||||
h = float(height)
|
||||
x = float(origin_x)
|
||||
y0 = float(origin_y)
|
||||
y1 = y0 + h
|
||||
text_h = h * 0.6
|
||||
|
||||
widths: list[float] = []
|
||||
for i, text in enumerate(compartments):
|
||||
if i == 1: # tolerance compartment — size to content
|
||||
widths.append(max(h * 1.6, len(text) * text_h * 0.75 + h * 0.5))
|
||||
else:
|
||||
widths.append(h)
|
||||
|
||||
labels: list[tuple[str, float, float]] = []
|
||||
dividers: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
cx = x
|
||||
for i, (text, w) in enumerate(zip(compartments, widths, strict=True)):
|
||||
labels.append((text, cx + w / 2.0, y0 + h / 2.0))
|
||||
cx += w
|
||||
if i < len(compartments) - 1:
|
||||
dividers.append(((cx, y0), (cx, y1)))
|
||||
total_w = sum(widths)
|
||||
|
||||
box = [
|
||||
(x, y0),
|
||||
(x + total_w, y0),
|
||||
(x + total_w, y1),
|
||||
(x, y1),
|
||||
(x, y0),
|
||||
]
|
||||
return {
|
||||
"box": box,
|
||||
"dividers": dividers,
|
||||
"labels": labels,
|
||||
"width": total_w,
|
||||
"height": h,
|
||||
"text_height": text_h,
|
||||
}
|
||||
|
||||
|
||||
def datum_triangle(
|
||||
x: float, y: float, size: float, *, down: bool = True
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Filled datum triangle (closed 3-point path) with apex at (x, y)."""
|
||||
s = float(size)
|
||||
if down:
|
||||
return [(x, y), (x - s / 2.0, y - s), (x + s / 2.0, y - s), (x, y)]
|
||||
return [(x, y), (x - s / 2.0, y + s), (x + s / 2.0, y + s), (x, y)]
|
||||
@@ -20,6 +20,7 @@ CritiqueFocus = Literal[
|
||||
"untrimmed_corner",
|
||||
"duplicate_entities",
|
||||
"construction_left",
|
||||
"gdt",
|
||||
]
|
||||
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
|
||||
|
||||
@@ -30,6 +31,7 @@ ALL_CRITIQUE_FOCUSES: tuple[CritiqueFocus, ...] = (
|
||||
"untrimmed_corner",
|
||||
"duplicate_entities",
|
||||
"construction_left",
|
||||
"gdt",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Scalar drawing-quality score + invalidity ratio.
|
||||
|
||||
Every 2025-26 CAD-generation benchmark (MUSE, CadBench) grades an *Invalidity
|
||||
Ratio*, not just shape similarity. This module turns the validator's and
|
||||
critique's severity counts into a single regression-trackable scalar so the
|
||||
premium workflow has an objective function (the prerequisite for an automated
|
||||
refiner loop and for smoke-test regression tracking).
|
||||
|
||||
The weights are a documented convention, not ground truth — they exist so the
|
||||
number moves in the right direction (errors hurt most, info barely registers)
|
||||
and can be asserted in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Penalty weights per severity. Errors dominate; info is nearly free.
|
||||
_W_ERROR = 25.0
|
||||
_W_WARNING = 8.0
|
||||
_W_INFO = 1.0
|
||||
|
||||
|
||||
def score_findings(
|
||||
errors: int,
|
||||
warnings: int,
|
||||
info: int = 0,
|
||||
*,
|
||||
w_error: float = _W_ERROR,
|
||||
w_warning: float = _W_WARNING,
|
||||
w_info: float = _W_INFO,
|
||||
) -> dict:
|
||||
"""Collapse severity counts into a 0-100 score + invalidity ratio.
|
||||
|
||||
- ``score``: ``100 - (errors·Wₑ + warnings·Wᵥᵥ + info·Wᵢ)`` clamped to [0, 100].
|
||||
A clean drawing scores 100.
|
||||
- ``invalidity_ratio``: ``errors / max(1, total_findings)`` — the fraction of
|
||||
findings that are hard failures (0.0 when there are no findings).
|
||||
- ``grade``: coarse A-F bucket for at-a-glance reporting.
|
||||
"""
|
||||
errors = max(0, int(errors))
|
||||
warnings = max(0, int(warnings))
|
||||
info = max(0, int(info))
|
||||
|
||||
penalty = errors * w_error + warnings * w_warning + info * w_info
|
||||
score = max(0.0, min(100.0, 100.0 - penalty))
|
||||
|
||||
total = errors + warnings + info
|
||||
invalidity_ratio = (errors / total) if total else 0.0
|
||||
|
||||
return {
|
||||
"score": round(score, 1),
|
||||
"invalidity_ratio": round(invalidity_ratio, 3),
|
||||
"grade": _grade(score),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"info": info,
|
||||
}
|
||||
|
||||
|
||||
def combine(*severity_dicts: dict) -> dict:
|
||||
"""Sum several ``{'error': n, 'warning': n, 'info': n}`` summaries then score.
|
||||
|
||||
Accepts the shapes emitted by both ``DrawingValidator`` (``summary``) and the
|
||||
finalize ``critique_summary`` so the finalize gate can score the union of the
|
||||
structural validator and the premium critique in one number.
|
||||
"""
|
||||
errors = warnings = info = 0
|
||||
for d in severity_dicts:
|
||||
if not d:
|
||||
continue
|
||||
errors += int(d.get("error", 0))
|
||||
warnings += int(d.get("warning", 0))
|
||||
info += int(d.get("info", 0))
|
||||
return score_findings(errors, warnings, info)
|
||||
|
||||
|
||||
def _grade(score: float) -> str:
|
||||
if score >= 95.0:
|
||||
return "A"
|
||||
if score >= 85.0:
|
||||
return "B"
|
||||
if score >= 70.0:
|
||||
return "C"
|
||||
if score >= 50.0:
|
||||
return "D"
|
||||
return "F"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""ISO 129-1 dimension tolerance helpers.
|
||||
|
||||
A production drawing without ±/limit callouts is not manufacturable — this was
|
||||
the dossier's "biggest functional gap". These helpers turn a small, explicit
|
||||
tolerance spec into the ezdxf/AutoCAD dimension-variable (DIMVAR) overrides that
|
||||
render tolerances, so `dimension_linear/radius/diameter` can emit toleranced
|
||||
dims on both backends.
|
||||
|
||||
Scope (intentionally CI-testable): explicit upper/lower deviations with
|
||||
symmetric / deviation / limit / basic display modes, plus a free-form text
|
||||
override. The full ISO 286 fit table (H7/g6 …) is a large authored dataset and
|
||||
is deferred to a follow-up; callers can already pass the resolved deviations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
TOL_MODES = ("none", "symmetric", "deviation", "limit", "basic")
|
||||
|
||||
|
||||
def build_dim_override(
|
||||
tol_upper: float | None = None,
|
||||
tol_lower: float | None = None,
|
||||
tol_mode: str = "none",
|
||||
text_override: str | None = None,
|
||||
) -> tuple[dict, str | None]:
|
||||
"""Return ``(dimvar_override, text)`` for an ezdxf ``add_*_dim`` call.
|
||||
|
||||
- ``tol_mode="symmetric"``: a single ± value (uses ``tol_upper``; ``±tol``).
|
||||
- ``tol_mode="deviation"``: distinct upper/lower deviations (e.g. +0.02/-0.01).
|
||||
- ``tol_mode="limit"``: upper/lower limits stacked instead of ± (DIMLIM).
|
||||
- ``tol_mode="basic"``: theoretically-exact (boxed) dimension, no tolerance.
|
||||
- ``tol_mode="none"``: no tolerance (default) — empty override.
|
||||
- ``text_override``: replaces the measured text ("<>" keeps the measurement).
|
||||
|
||||
Raises ValueError for an unknown mode or a mode missing its required values.
|
||||
"""
|
||||
mode = (tol_mode or "none").strip().lower()
|
||||
if mode not in TOL_MODES:
|
||||
raise ValueError(
|
||||
f"build_dim_override: unknown tol_mode {tol_mode!r}. Use one of {TOL_MODES}."
|
||||
)
|
||||
|
||||
override: dict = {}
|
||||
|
||||
if mode == "symmetric":
|
||||
if tol_upper is None:
|
||||
raise ValueError("tol_mode='symmetric' requires tol_upper (the ± value).")
|
||||
t = abs(float(tol_upper))
|
||||
override.update({"dimtol": 1, "dimlim": 0, "dimtp": t, "dimtm": t})
|
||||
elif mode == "deviation":
|
||||
if tol_upper is None or tol_lower is None:
|
||||
raise ValueError("tol_mode='deviation' requires tol_upper and tol_lower.")
|
||||
override.update(
|
||||
{
|
||||
"dimtol": 1,
|
||||
"dimlim": 0,
|
||||
"dimtp": float(tol_upper),
|
||||
"dimtm": abs(float(tol_lower)),
|
||||
}
|
||||
)
|
||||
elif mode == "limit":
|
||||
if tol_upper is None or tol_lower is None:
|
||||
raise ValueError("tol_mode='limit' requires tol_upper and tol_lower.")
|
||||
override.update(
|
||||
{
|
||||
"dimlim": 1,
|
||||
"dimtol": 0,
|
||||
"dimtp": float(tol_upper),
|
||||
"dimtm": abs(float(tol_lower)),
|
||||
}
|
||||
)
|
||||
elif mode == "basic":
|
||||
# Theoretically-exact dimension: a box around the text. In AutoCAD a
|
||||
# negative DIMGAP draws that box.
|
||||
override.update({"dimgap": -1.0})
|
||||
|
||||
text = text_override if text_override not in (None, "") else None
|
||||
return override, text
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "autocad-mcp-pro"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -806,10 +806,19 @@ async def dimension_linear(
|
||||
dim_y: Annotated[float, "Dimension line position Y"],
|
||||
rotation: Annotated[float, "Angle of the measured dimension (0=horizontal, 90=vertical)"] = 0.0,
|
||||
layer: Annotated[str | None, "Layer name"] = None,
|
||||
tol_upper: Annotated[float | None, "Upper deviation (mm), e.g. 0.02 for +0.02"] = None,
|
||||
tol_lower: Annotated[float | None, "Lower deviation (mm), e.g. 0.01 for -0.01"] = None,
|
||||
tol_mode: Annotated[str, Field(default="none",
|
||||
description="ISO 129 tolerance display: none | symmetric (±tol_upper) | "
|
||||
"deviation (+tol_upper/-tol_lower) | limit (stacked limits) | basic (boxed).")] = "none",
|
||||
text_override: Annotated[str | None, "Replace the measured text ('<>' keeps the measurement)"] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Create a linear dimension measuring horizontal or vertical distance."""
|
||||
result = await _backend(ctx).dimension_linear(x1, y1, x2, y2, dim_x, dim_y, rotation, layer)
|
||||
"""Create a linear dimension, optionally with an ISO 129 tolerance callout."""
|
||||
result = await _backend(ctx).dimension_linear(
|
||||
x1, y1, x2, y2, dim_x, dim_y, rotation, layer,
|
||||
tol_upper, tol_lower, tol_mode, text_override,
|
||||
)
|
||||
return _dc(result)
|
||||
|
||||
|
||||
@@ -866,10 +875,18 @@ async def dimension_radius(
|
||||
chord_y: Annotated[float, "Point on the circle/arc Y"],
|
||||
leader_length: Annotated[float, "Length of the leader line"] = 10.0,
|
||||
layer: Annotated[str | None, "Layer name"] = None,
|
||||
tol_upper: Annotated[float | None, "Upper deviation (mm)"] = None,
|
||||
tol_lower: Annotated[float | None, "Lower deviation (mm)"] = None,
|
||||
tol_mode: Annotated[str, Field(default="none",
|
||||
description="ISO 129 tolerance display: none | symmetric | deviation | limit | basic.")] = "none",
|
||||
text_override: Annotated[str | None, "Replace the measured text ('<>' keeps the measurement)"] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Create a radius dimension for a circle or arc."""
|
||||
result = await _backend(ctx).dimension_radius(center_x, center_y, chord_x, chord_y, leader_length, layer)
|
||||
"""Create a radius dimension for a circle or arc, optionally toleranced."""
|
||||
result = await _backend(ctx).dimension_radius(
|
||||
center_x, center_y, chord_x, chord_y, leader_length, layer,
|
||||
tol_upper, tol_lower, tol_mode, text_override,
|
||||
)
|
||||
return _dc(result)
|
||||
|
||||
|
||||
@@ -884,10 +901,18 @@ async def dimension_diameter(
|
||||
y2: Annotated[float, "Second point on diameter Y"],
|
||||
leader_length: Annotated[float, "Leader line length"] = 10.0,
|
||||
layer: Annotated[str | None, "Layer name"] = None,
|
||||
tol_upper: Annotated[float | None, "Upper deviation (mm)"] = None,
|
||||
tol_lower: Annotated[float | None, "Lower deviation (mm)"] = None,
|
||||
tol_mode: Annotated[str, Field(default="none",
|
||||
description="ISO 129 tolerance display: none | symmetric | deviation | limit | basic.")] = "none",
|
||||
text_override: Annotated[str | None, "Replace the measured text ('<>' keeps the measurement)"] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Create a diameter dimension for a circle (two points on opposite sides)."""
|
||||
result = await _backend(ctx).dimension_diameter(x1, y1, x2, y2, leader_length, layer)
|
||||
"""Create a diameter dimension for a circle, optionally toleranced (e.g. ⌀20 H7)."""
|
||||
result = await _backend(ctx).dimension_diameter(
|
||||
x1, y1, x2, y2, leader_length, layer,
|
||||
tol_upper, tol_lower, tol_mode, text_override,
|
||||
)
|
||||
return _dc(result)
|
||||
|
||||
|
||||
@@ -1140,6 +1165,56 @@ async def entity_set_properties(
|
||||
return await _backend(ctx).entity_set_properties(handle, layer, color, linetype, lineweight, visible)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
annotations={"title": "Edit Text", "readOnlyHint": False, "destructiveHint": False},
|
||||
tags={"entity", "modify"},
|
||||
)
|
||||
async def entity_edit_text(
|
||||
handle: Annotated[str, "Handle of an existing TEXT or MTEXT entity"],
|
||||
text: Annotated[str | None, "New text content (unchanged if omitted)"] = None,
|
||||
height: Annotated[float | None, "New text height (unchanged if omitted)"] = None,
|
||||
rotation: Annotated[float | None, "New rotation in degrees (unchanged if omitted)"] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Edit an existing text label in place — change its content, height, or rotation.
|
||||
|
||||
Use this to rename/relabel without deleting and recreating (which would lose
|
||||
the handle). Works on both TEXT and MTEXT.
|
||||
"""
|
||||
await ctx.debug(f"Editing text entity {handle}")
|
||||
result = await _backend(ctx).entity_edit_text(handle, text, height, rotation)
|
||||
return _dc(result)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
annotations={"title": "Edit Geometry", "readOnlyHint": False, "destructiveHint": False},
|
||||
tags={"entity", "modify"},
|
||||
)
|
||||
async def entity_edit_geometry(
|
||||
handle: Annotated[str, "Handle of an existing CIRCLE, LINE, or ARC"],
|
||||
cx: Annotated[float | None, "New center X (CIRCLE/ARC)"] = None,
|
||||
cy: Annotated[float | None, "New center Y (CIRCLE/ARC)"] = None,
|
||||
radius: Annotated[float | None, "New radius (CIRCLE/ARC)"] = None,
|
||||
x1: Annotated[float | None, "New start X (LINE)"] = None,
|
||||
y1: Annotated[float | None, "New start Y (LINE)"] = None,
|
||||
x2: Annotated[float | None, "New end X (LINE)"] = None,
|
||||
y2: Annotated[float | None, "New end Y (LINE)"] = None,
|
||||
start_angle: Annotated[float | None, "New start angle in degrees (ARC)"] = None,
|
||||
end_angle: Annotated[float | None, "New end angle in degrees (ARC)"] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Edit the defining geometry of an existing entity in place (no delete/recreate).
|
||||
|
||||
CIRCLE: cx/cy/radius · LINE: x1/y1/x2/y2 · ARC: cx/cy/radius/start_angle/end_angle.
|
||||
Any argument left out is unchanged; the handle is preserved.
|
||||
"""
|
||||
await ctx.debug(f"Editing geometry of entity {handle}")
|
||||
result = await _backend(ctx).entity_edit_geometry(
|
||||
handle, cx, cy, radius, x1, y1, x2, y2, start_angle, end_angle,
|
||||
)
|
||||
return _dc(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── SECTION 5: Entity Query (4 tools) ───────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2181,6 +2256,31 @@ async def system_set_variable(
|
||||
return await _backend(ctx).system_set_variable(name, value)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
annotations={"title": "Drawing Settings (read / change)", "readOnlyHint": False,
|
||||
"destructiveHint": False},
|
||||
tags={"system"},
|
||||
)
|
||||
async def drawing_settings(
|
||||
settings: Annotated[dict | None, Field(default=None, description=(
|
||||
"Omit to READ every setting; pass a dict to CHANGE them. Friendly keys: "
|
||||
"units (mm/cm/m/inch/feet), linear_precision, angular_precision, ltscale, "
|
||||
"dimscale, text_size, point_mode, point_size, osmode, fillet_radius. "
|
||||
"Example: {\"units\": \"mm\", \"dimscale\": 1.0, \"linear_precision\": 2}."))] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Read or change common AutoCAD drawing settings by friendly name.
|
||||
|
||||
A convenience facade over the system variables (INSUNITS, LUPREC, LTSCALE,
|
||||
DIMSCALE, TEXTSIZE, OSMODE, …) so the user can say "set units to mm and
|
||||
dimension scale to 1" without memorising sysvar names. Call with no argument
|
||||
to get a full snapshot of the current settings.
|
||||
"""
|
||||
if settings:
|
||||
await ctx.info(f"Applying drawing settings: {', '.join(settings)}")
|
||||
return await _backend(ctx).drawing_settings(settings)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
annotations={"title": "Run AutoCAD Command", "readOnlyHint": False},
|
||||
tags={"system"},
|
||||
@@ -2468,6 +2568,12 @@ async def drawing_finalize(
|
||||
payload["critique"] = [issue.to_dict() for issue in critique_issues]
|
||||
payload["critique_summary"] = crit_summary
|
||||
|
||||
# I4 — a single regression-trackable scalar over the union of the structural
|
||||
# validator and the premium critique (MUSE/CadBench grade an Invalidity Ratio,
|
||||
# not shape). 100 = clean; errors dominate the penalty.
|
||||
from engineering.scoring import combine
|
||||
payload["score"] = combine(result.summary, crit_summary)
|
||||
|
||||
info = await backend.drawing_info()
|
||||
payload["dwg_path"] = getattr(info, "full_path", "")
|
||||
|
||||
@@ -2696,6 +2802,71 @@ async def entity_select_smart(
|
||||
return [_dc(e) for e in result]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── SECTION 14: GD&T (ISO 1101 / ASME Y14.5) ────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2D geometric tolerancing — feature control frames + datum features — composed
|
||||
# from LINE + TEXT so the same frame renders on COM and ezdxf. The datum-
|
||||
# consistency rule is enforced by the `gdt` critique focus at finalize time.
|
||||
|
||||
@mcp.tool(
|
||||
annotations={"title": "GD&T: Feature Control Frame (ISO 1101)",
|
||||
"destructiveHint": False},
|
||||
tags={"engineering", "gdt"},
|
||||
)
|
||||
async def gd_frame(
|
||||
symbol: Annotated[str, Field(description=(
|
||||
"Geometric characteristic: straightness, flatness, circularity, "
|
||||
"cylindricity, profile_line, profile_surface, angularity, "
|
||||
"perpendicularity, parallelism, position, concentricity, symmetry, "
|
||||
"circular_runout, total_runout."))],
|
||||
tolerance: Annotated[float, "Tolerance zone value (mm)."],
|
||||
x: Annotated[float, "Frame bottom-left corner X."],
|
||||
y: Annotated[float, "Frame bottom-left corner Y."],
|
||||
datums: Annotated[list[str] | None, Field(default=None,
|
||||
description="Ordered datum references, e.g. ['A','B']. Required for "
|
||||
"orientation/location/runout characteristics.")] = None,
|
||||
height: Annotated[float, Field(default=5.0, gt=0,
|
||||
description="Frame height (mm); text scales with it.")] = 5.0,
|
||||
diameter: Annotated[bool, Field(default=False,
|
||||
description="Prefix ⌀ for a cylindrical (diametral) tolerance zone.")] = False,
|
||||
modifier: Annotated[str | None, Field(default=None,
|
||||
description="Material-condition modifier: M (MMC), L (LMC), S (RFS).")] = None,
|
||||
layer: Annotated[str | None, "Layer (defaults to the active DIM layer)."] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Draw an ISO 1101 feature control frame from LINE + TEXT primitives.
|
||||
|
||||
Renders identically on COM and ezdxf. Referenced datums are recorded so the
|
||||
`gdt` critique focus flags any datum with no matching datum feature.
|
||||
"""
|
||||
return await _backend(ctx).draw_feature_control_frame(
|
||||
symbol, tolerance, x, y, datums, height, diameter, modifier, layer,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
annotations={"title": "GD&T: Datum Feature (ISO 1101)",
|
||||
"destructiveHint": False},
|
||||
tags={"engineering", "gdt"},
|
||||
)
|
||||
async def datum_feature(
|
||||
letter: Annotated[str, "Datum letter, e.g. 'A' (avoid I, O, Q per ISO 1101)."],
|
||||
x: Annotated[float, "Datum triangle apex X (on the referenced feature)."],
|
||||
y: Annotated[float, "Datum triangle apex Y."],
|
||||
size: Annotated[float, Field(default=5.0, gt=0,
|
||||
description="Triangle/label size (mm).")] = 5.0,
|
||||
layer: Annotated[str | None, "Layer (defaults to the active DIM layer)."] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Place a datum feature symbol (filled triangle + boxed letter).
|
||||
|
||||
Establishes the datum so a feature control frame referencing this letter
|
||||
passes the `gdt` critique focus.
|
||||
"""
|
||||
return await _backend(ctx).draw_datum_feature(letter, x, y, size, layer)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for ISO 129 dimension tolerances (engineering/tolerances.py + the
|
||||
ezdxf dimension_* override plumbing)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.tolerances import build_dim_override
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── pure: override builder ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_none_mode_is_empty():
|
||||
override, text = build_dim_override(tol_mode="none")
|
||||
assert override == {}
|
||||
assert text is None
|
||||
|
||||
|
||||
async def test_symmetric_sets_equal_tp_tm():
|
||||
override, _ = build_dim_override(tol_upper=0.05, tol_mode="symmetric")
|
||||
assert override["dimtol"] == 1
|
||||
assert override["dimtp"] == 0.05
|
||||
assert override["dimtm"] == 0.05
|
||||
|
||||
|
||||
async def test_deviation_sets_distinct_tp_tm():
|
||||
override, _ = build_dim_override(0.02, 0.01, "deviation")
|
||||
assert override["dimtp"] == 0.02
|
||||
assert override["dimtm"] == 0.01
|
||||
assert override["dimtol"] == 1
|
||||
|
||||
|
||||
async def test_limit_mode_sets_dimlim():
|
||||
override, _ = build_dim_override(0.02, 0.01, "limit")
|
||||
assert override["dimlim"] == 1
|
||||
assert override["dimtol"] == 0
|
||||
|
||||
|
||||
async def test_basic_mode_boxes_text():
|
||||
override, _ = build_dim_override(tol_mode="basic")
|
||||
assert override["dimgap"] < 0
|
||||
|
||||
|
||||
async def test_text_override_passthrough():
|
||||
_, text = build_dim_override(text_override="20 H7")
|
||||
assert text == "20 H7"
|
||||
|
||||
|
||||
async def test_bad_mode_and_missing_values_raise():
|
||||
with pytest.raises(ValueError):
|
||||
build_dim_override(tol_mode="wat")
|
||||
with pytest.raises(ValueError):
|
||||
build_dim_override(tol_mode="symmetric") # no tol_upper
|
||||
with pytest.raises(ValueError):
|
||||
build_dim_override(0.02, None, "deviation") # no tol_lower
|
||||
|
||||
|
||||
# ── backend: toleranced dims are created without error ──────────────────────
|
||||
|
||||
|
||||
async def test_linear_dim_with_symmetric_tolerance(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
dim = await backend.dimension_linear(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
0,
|
||||
50,
|
||||
20,
|
||||
layer="DIM",
|
||||
tol_upper=0.05,
|
||||
tol_mode="symmetric",
|
||||
)
|
||||
assert dim.handle
|
||||
assert dim.type in ("DIMENSION", "DIMLINEAR")
|
||||
|
||||
|
||||
async def test_diameter_dim_with_text_override(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
|
||||
assert c.handle
|
||||
dim = await backend.dimension_diameter(
|
||||
-10,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
layer="DIM",
|
||||
text_override="<> H7",
|
||||
)
|
||||
assert dim.handle
|
||||
|
||||
|
||||
async def test_radius_dim_with_deviation_tolerance(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
dim = await backend.dimension_radius(
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
layer="DIM",
|
||||
tol_upper=0.03,
|
||||
tol_lower=0.01,
|
||||
tol_mode="deviation",
|
||||
)
|
||||
assert dim.handle
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for the drawing_settings facade — friendly read/change of AutoCAD
|
||||
system variables (units, precision, scales, osnap, …)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_read_snapshot_returns_known_keys(backend):
|
||||
snap = await backend.drawing_settings()
|
||||
assert snap["ok"] is True
|
||||
s = snap["settings"]
|
||||
for key in ("units", "linear_precision", "ltscale", "dimscale", "osmode"):
|
||||
assert key in s
|
||||
|
||||
|
||||
async def test_set_and_read_back_units_by_name(backend):
|
||||
res = await backend.drawing_settings({"units": "mm"})
|
||||
assert res["ok"] is True
|
||||
assert res["applied"]["units"] == "mm"
|
||||
snap = await backend.drawing_settings()
|
||||
assert snap["settings"]["units"]["name"] == "mm"
|
||||
assert snap["settings"]["units"]["code"] == 4
|
||||
|
||||
|
||||
async def test_set_units_cm_and_inch_codes(backend):
|
||||
await backend.drawing_settings({"units": "cm"})
|
||||
assert (await backend.drawing_settings())["settings"]["units"]["code"] == 5
|
||||
await backend.drawing_settings({"units": "inch"})
|
||||
assert (await backend.drawing_settings())["settings"]["units"]["code"] == 1
|
||||
|
||||
|
||||
async def test_set_numeric_settings(backend):
|
||||
res = await backend.drawing_settings({
|
||||
"dimscale": 2.0, "linear_precision": 3, "ltscale": 0.5,
|
||||
})
|
||||
assert res["ok"] is True
|
||||
snap = (await backend.drawing_settings())["settings"]
|
||||
assert snap["dimscale"] == 2.0
|
||||
assert snap["linear_precision"] == 3
|
||||
assert snap["ltscale"] == 0.5
|
||||
|
||||
|
||||
async def test_unknown_key_reports_error_without_failing_others(backend):
|
||||
res = await backend.drawing_settings({"dimscale": 1.5, "bogus_key": 1})
|
||||
assert res["applied"]["dimscale"] == 1.5
|
||||
assert "bogus_key" in res["errors"]
|
||||
assert res["ok"] is False
|
||||
|
||||
|
||||
async def test_bad_unit_value_is_reported(backend):
|
||||
res = await backend.drawing_settings({"units": "furlongs"})
|
||||
assert "units" in res["errors"]
|
||||
|
||||
|
||||
# ── server tool wiring ──────────────────────────────────────────────────────
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, backend):
|
||||
self.lifespan_context = {"backend": backend}
|
||||
|
||||
async def info(self, *a, **k):
|
||||
pass
|
||||
|
||||
|
||||
async def test_server_tool_reads_and_writes(backend):
|
||||
import server
|
||||
|
||||
ctx = _FakeCtx(backend)
|
||||
write = await server.drawing_settings(settings={"units": "mm", "dimscale": 1.0}, ctx=ctx)
|
||||
assert write["ok"] is True
|
||||
read = await server.drawing_settings(ctx=ctx)
|
||||
assert read["settings"]["units"]["name"] == "mm"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests for in-place editing: entity_edit_text and entity_edit_geometry.
|
||||
|
||||
These let a user re-label text or re-drive geometry without deleting and
|
||||
recreating (which would lose the entity handle)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── entity_edit_text ────────────────────────────────────────────────────────
|
||||
|
||||
async def test_edit_text_changes_content_keeps_handle(backend):
|
||||
t = await backend.entity_create_text("OLD LABEL", 0, 0, height=2.5, layer="TEXT")
|
||||
updated = await backend.entity_edit_text(t.handle, text="NEW LABEL")
|
||||
assert updated.handle == t.handle # handle preserved
|
||||
assert updated.properties["text"] == "NEW LABEL"
|
||||
|
||||
|
||||
async def test_edit_text_changes_height_and_rotation(backend):
|
||||
t = await backend.entity_create_text("HELLO", 0, 0, height=2.5, layer="TEXT")
|
||||
updated = await backend.entity_edit_text(t.handle, height=5.0, rotation=90.0)
|
||||
assert math.isclose(updated.properties["height"], 5.0)
|
||||
assert math.isclose(updated.properties["rotation"], 90.0)
|
||||
# Content untouched when text is omitted.
|
||||
assert updated.properties["text"] == "HELLO"
|
||||
|
||||
|
||||
async def test_edit_mtext_content(backend):
|
||||
m = await backend.entity_create_mtext("first draft", 0, 0, width=100, height=3, layer="TEXT")
|
||||
updated = await backend.entity_edit_text(m.handle, text="final copy")
|
||||
assert "final copy" in updated.properties["text"]
|
||||
|
||||
|
||||
async def test_edit_text_rejects_non_text(backend):
|
||||
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
|
||||
with pytest.raises(RuntimeError):
|
||||
await backend.entity_edit_text(c.handle, text="nope")
|
||||
|
||||
|
||||
# ── entity_edit_geometry ────────────────────────────────────────────────────
|
||||
|
||||
async def test_edit_circle_radius_and_center(backend):
|
||||
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
|
||||
updated = await backend.entity_edit_geometry(c.handle, cx=5, cy=5, radius=20)
|
||||
assert updated.handle == c.handle
|
||||
assert math.isclose(updated.properties["radius"], 20.0)
|
||||
center = updated.properties["center"]
|
||||
assert math.isclose(center[0], 5.0) and math.isclose(center[1], 5.0)
|
||||
|
||||
|
||||
async def test_edit_line_endpoints(backend):
|
||||
ln = await backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
||||
updated = await backend.entity_edit_geometry(ln.handle, x2=10, y2=10)
|
||||
end = updated.properties["end"]
|
||||
assert math.isclose(end[0], 10.0) and math.isclose(end[1], 10.0)
|
||||
# Start unchanged.
|
||||
start = updated.properties["start"]
|
||||
assert math.isclose(start[0], 0.0) and math.isclose(start[1], 0.0)
|
||||
|
||||
|
||||
async def test_edit_arc_angles(backend):
|
||||
arc = await backend.entity_create_arc(0, 0, 10, 0, 90, layer="GEOMETRY")
|
||||
updated = await backend.entity_edit_geometry(arc.handle, start_angle=10, end_angle=170)
|
||||
assert math.isclose(updated.properties["start_angle"], 10.0)
|
||||
assert math.isclose(updated.properties["end_angle"], 170.0)
|
||||
|
||||
|
||||
async def test_edit_geometry_rejects_unsupported(backend):
|
||||
poly = await backend.entity_create_polyline([[0, 0], [1, 1]], layer="GEOMETRY")
|
||||
with pytest.raises(RuntimeError):
|
||||
await backend.entity_edit_geometry(poly.handle, radius=5)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Tests for 2D GD&T (ISO 1101) — pure layout math, backend composition, and
|
||||
the datum-consistency critique focus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.gdt import (
|
||||
DIAMETER_PREFIX,
|
||||
GEOMETRIC_SYMBOLS,
|
||||
MATERIAL_MODIFIERS,
|
||||
datum_triangle,
|
||||
fcf_compartments,
|
||||
fcf_layout,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── pure: compartments ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_form_tolerance_needs_no_datum():
|
||||
comps = fcf_compartments("flatness", 0.1)
|
||||
assert comps[0] == GEOMETRIC_SYMBOLS["flatness"]
|
||||
assert comps[1] == "0.1"
|
||||
assert len(comps) == 2
|
||||
|
||||
|
||||
async def test_position_requires_datum():
|
||||
with pytest.raises(ValueError):
|
||||
fcf_compartments("position", 0.2) # location control, no datum
|
||||
|
||||
|
||||
async def test_position_with_datums_and_diameter_and_modifier():
|
||||
comps = fcf_compartments(
|
||||
"position",
|
||||
0.2,
|
||||
["A", "B", "C"],
|
||||
diameter=True,
|
||||
modifier="M",
|
||||
)
|
||||
assert comps[0] == GEOMETRIC_SYMBOLS["position"]
|
||||
assert comps[1] == f"{DIAMETER_PREFIX}0.2{MATERIAL_MODIFIERS['M']}"
|
||||
assert comps[2:] == ["A", "B", "C"]
|
||||
|
||||
|
||||
async def test_unknown_symbol_and_modifier_raise():
|
||||
with pytest.raises(ValueError):
|
||||
fcf_compartments("bogus", 0.1)
|
||||
with pytest.raises(ValueError):
|
||||
fcf_compartments("flatness", 0.1, modifier="Z")
|
||||
|
||||
|
||||
# ── pure: layout geometry ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_fcf_layout_box_closed_and_dividers_count():
|
||||
comps = fcf_compartments("position", 0.2, ["A", "B"], diameter=True)
|
||||
lay = fcf_layout(comps, 0.0, 0.0, 5.0)
|
||||
# Closed rectangle: first == last point.
|
||||
assert lay["box"][0] == lay["box"][-1]
|
||||
# n compartments -> n-1 internal dividers, n labels.
|
||||
assert len(lay["dividers"]) == len(comps) - 1
|
||||
assert len(lay["labels"]) == len(comps)
|
||||
assert lay["width"] > 0 and lay["height"] == 5.0
|
||||
|
||||
|
||||
async def test_datum_triangle_is_closed_triangle():
|
||||
tri = datum_triangle(10.0, 20.0, 4.0, down=True)
|
||||
assert tri[0] == (10.0, 20.0) # apex
|
||||
assert tri[0] == tri[-1] # closed
|
||||
assert len(tri) == 4
|
||||
|
||||
|
||||
# ── backend composition ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_draw_feature_control_frame_creates_entities(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
res = await backend.draw_feature_control_frame(
|
||||
"perpendicularity",
|
||||
0.05,
|
||||
0.0,
|
||||
0.0,
|
||||
datums=["A"],
|
||||
height=5.0,
|
||||
)
|
||||
assert res["ok"] is True
|
||||
assert res["layer"] == "DIM"
|
||||
# box + 2 dividers (3 compartments) + 3 texts = several handles
|
||||
assert len(res["handles"]) >= 5
|
||||
for h in res["handles"]:
|
||||
assert await backend.entity_get(h) is not None
|
||||
|
||||
|
||||
async def test_draw_datum_feature_records_letter(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
res = await backend.draw_datum_feature("A", 0.0, 0.0, size=5.0)
|
||||
assert res["datum"] == "A"
|
||||
assert "A" in backend._gdt_datums_defined
|
||||
|
||||
|
||||
# ── gdt critique focus ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_gdt_critique_flags_missing_datum(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
await backend.draw_feature_control_frame(
|
||||
"position",
|
||||
0.2,
|
||||
0.0,
|
||||
0.0,
|
||||
datums=["A", "B"],
|
||||
diameter=True,
|
||||
)
|
||||
issues = await backend.drawing_critique(focus=["gdt"])
|
||||
assert len(issues) == 1
|
||||
assert issues[0].focus == "gdt"
|
||||
assert issues[0].severity == "error"
|
||||
assert set(issues[0].detail["missing_datums"]) == {"A", "B"}
|
||||
|
||||
|
||||
async def test_gdt_critique_passes_when_datums_defined(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
await backend.draw_feature_control_frame(
|
||||
"position",
|
||||
0.2,
|
||||
0.0,
|
||||
0.0,
|
||||
datums=["A"],
|
||||
diameter=True,
|
||||
)
|
||||
await backend.draw_datum_feature("A", 50.0, 0.0)
|
||||
issues = await backend.drawing_critique(focus=["gdt"])
|
||||
assert issues == []
|
||||
|
||||
|
||||
async def test_clean_drawing_gdt_focus_is_noop(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
# No FCFs at all — gdt must not fire, and full critique stays clean.
|
||||
assert await backend.drawing_critique(focus=["gdt"]) == []
|
||||
assert await backend.drawing_critique() == []
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for the scalar drawing-score / invalidity-ratio (engineering/scoring.py)
|
||||
and its wiring into drawing_finalize."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.scoring import combine, score_findings
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_clean_drawing_scores_100():
|
||||
s = score_findings(0, 0, 0)
|
||||
assert s["score"] == 100.0
|
||||
assert s["invalidity_ratio"] == 0.0
|
||||
assert s["grade"] == "A"
|
||||
|
||||
|
||||
async def test_errors_dominate_penalty():
|
||||
err = score_findings(1, 0, 0)["score"]
|
||||
warn = score_findings(0, 1, 0)["score"]
|
||||
info = score_findings(0, 0, 1)["score"]
|
||||
assert err < warn < info < 100.0
|
||||
|
||||
|
||||
async def test_score_clamped_to_zero():
|
||||
s = score_findings(100, 100, 100)
|
||||
assert s["score"] == 0.0
|
||||
assert s["grade"] == "F"
|
||||
|
||||
|
||||
async def test_invalidity_ratio_is_error_fraction():
|
||||
s = score_findings(1, 3, 0) # 1 of 4 findings is a hard error
|
||||
assert s["invalidity_ratio"] == 0.25
|
||||
|
||||
|
||||
async def test_combine_sums_summaries():
|
||||
a = {"error": 1, "warning": 0, "info": 0}
|
||||
b = {"error": 0, "warning": 2, "info": 1}
|
||||
combined = combine(a, b)
|
||||
assert combined["errors"] == 1
|
||||
assert combined["warnings"] == 2
|
||||
assert combined["info"] == 1
|
||||
|
||||
|
||||
# ── finalize wiring ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, backend):
|
||||
self.lifespan_context = {"backend": backend}
|
||||
|
||||
async def warning(self, message):
|
||||
pass
|
||||
|
||||
|
||||
async def test_finalize_payload_carries_score(backend, tmp_path):
|
||||
import server
|
||||
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
await backend.entity_create_line(0, 0, 100, 0, layer="GEOMETRY")
|
||||
ctx = _FakeCtx(backend)
|
||||
payload = await server.drawing_finalize(
|
||||
save_path=str(tmp_path / "part.dxf"),
|
||||
ctx=ctx,
|
||||
)
|
||||
assert "score" in payload
|
||||
assert 0.0 <= payload["score"]["score"] <= 100.0
|
||||
assert "invalidity_ratio" in payload["score"]
|
||||
assert payload["score"]["grade"] in ("A", "B", "C", "D", "F")
|
||||
Reference in New Issue
Block a user