diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce53a4..8769e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ views and UCS, and the live-only group (launch, preferences, document properties, operator prompts) refused honestly headlessly. Spec: `docs/superpowers/specs/2026-09-16-v1.6-settings-design.md`. +Tracks B and G of 1.6: the complete mechanical 2D part drawer and the sheet +standard. 25 tools in four new sections — mechanical parts (21), standard +parts (22), mechanical annotation (23) and sheet and delivery (24) — one part +model and one view engine instead of a zoo of per-part generators, a new +`mech` pack, six `mech_*` critique focuses and four new contract members +(`xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`). Spec: +`docs/superpowers/specs/2026-09-22-v1.6-mech-design.md`. + ### Added - **Styles — 10 tools, pack `core`.** `dimstyle_list/create/modify/set_current`, @@ -74,6 +82,28 @@ properties, operator prompts) refused honestly headlessly. Spec: `v1.5.1`: **26 / 32 → 32 / 32, six `miss → pass`, zero regressed**. `scripts/smoke_settings_com.py` runs the track once against the live seat (record below). +- **Benchmark evidence for tracks B and G.** `benchmarks/tasks_v5.py` (18 + tasks, the default matrix; v4 / v3 / v2 stay addressable) adds + `mech_assembly`: a flange-coupling A3 sheet built through the code the tools + call — frame and title block, a hub with a full section and chain + dimensions, a flange with its bolt-circle end view, ISO 4014 bolts and ISO + 4032 nuts, the parts list read off the drawing and linked balloons — gated on + `drawing_critique(focus=None)` returning zero issues and a finalize score of + at least 90 (it scores 100); a test drops one balloon and watches the gate + fail by name. The correctness suite grows to 39 with `mech_part_roundtrip`, + `mech_section_hatch_area` (1200 mm² read back out of the drawing), + `mech_iso286_on_dimension`, `mech_thread_unrepresented_is_caught`, + `std_part_iso4014_m12`, `sheet_frame_iso5457_a3` and `bom_balloon_link`. + A/B against `v1.5.1`: **26 / 39 → 39 / 39, thirteen `miss → pass`, zero + regressed**. The token suite measures a `TOOL_PACKS=core,mech` row: 193 + tools / 61,673 tokens against 224 / 72,163 by default. +- **Live COM smoke.** `scripts/smoke_mech_com.py` executed once on AutoCAD + 2026 (`25.1s (LMS Tech)`): the A3 frame and ISO 7200 title block, a two-step + shaft with a full section and 12 chain dimensions, an ISO 4014 M12x60 block + carrying 1 ACADMCP_MECH chunk, an ISO 7573 parts list read off the drawing + with its linked balloon, an xref attached and detached, and a real + 31687-byte `.dwg` written by ActiveX `SaveAs` — `drawing_critique(focus=None)` + returned 0 issues (record below). - **P&ID — 9 tools, pack `pid`.** - `pid_symbol_list` / `pid_symbol_insert`: a 43-symbol ISO 10628-2 / @@ -153,6 +183,33 @@ properties, operator prompts) refused honestly headlessly. Spec: ### Fixed +- **Found by the tracks B + G evidence** (each with a test that fails on the + old code): + - `dimension_diameter` put its text beyond the *second* chord point + headlessly and beyond the *first* on AutoCAD (`AddDimDiametric`'s + ChordPoint, measured: a ⌀50 from (x, 170) to (x, 220) annotated at + (x, 160)). The two engines annotated opposite sides of the same part, and + headlessly every front-view diameter of `mech_part_draw` stood on its own + chain dimension — `dim_overlap` fired on any single-step disk. The + headless engine now follows ActiveX. + - On a live seat `mech_part_draw` died after drawing every view: its anchor + POINT goes on `MECH`, and ActiveX refuses `entity.Layer` on a missing + layer (`Key not found`) where ezdxf creates it. Every mechanical draw + path now ensures its target layers before the first entity. + - `bom_extract` returned nothing live: ActiveX names a block reference + `BLOCKREFERENCE`, and the parts list filtered on `INSERT` only. Both + names are read. + - The live `_entity_info` reported no `text_position` for a dimension (the + entity arrives narrowed to `IAcadEntity`, which has no `TextPosition`), + so `dim_overlap` compared bounding-box centres — a diameter and its bore + read "0.00 mm apart". It is read through the concrete class now. + - `pid_graph` raised `KeyError: 'start'` inside `drawing_critique(focus=None)` + on a LINE whose point read AutoCAD had refused while busy; such a line is + left out instead. + - The validator's `fake_dimension_text` read a standard-part designation + (`ISO 4014 - M12x60`) as a hand-typed dimension, so every parts list + drawn as text cost its sheet a warning. The designation is exempt; a + measurement beside it still flags. - **Track A hardening** (`tests/test_track_a_hardening.py`; the remaining review findings are filed with a disposition each in `docs/analysis/track-a-review-backlog.md`): @@ -716,6 +773,53 @@ The pick path was executed live earlier in the track (Task 23's measurement: `GetPoint` answers in WCS while `GetEntity` answers in the current UCS). Each plot also dropped AutoCAD's `plot.log` into the repo root; it is ignored now. +### Live COM smoke — mechanical parts and the sheet (tracks B + G) + +`scripts/smoke_mech_com.py`, AutoCAD 2026 (`25.1s (LMS Tech)`), Windows 11, +2026-09-23, in new documents only (an xref source saved and closed first, then +the sheet) — `apply_layer_set("mech")` → `apply_titleblock` (A3, zones, marks) +→ `draw_part` → `add_view(section)` → `dimension_part` → `insert_std_part` → +`weld_symbol_prims` → `extract_records` → `draw_bom_table` → `add_balloon` → +`xref_attach` / `xref_manage("list")` / `xref_manage("detach")` → +`run_critique(focus=None)` → `drawing_export_dwg("R2018")`, all through the COM +engine, exit 0, every scratch document closed afterwards: + +```json +{ + "autocad": "25.1s (LMS Tech)", + "part_id": "103", + "views": 2, + "section_omitted": [], + "dimensions": 12, + "bolt_handle": "2F7", + "xdata_chunks": 1, + "parts_list_rows": 1, + "parts_list_representation": "native", + "balloon": "31A", + "xref_attached": "XREF_SOURCE", + "xref_listed": 1, + "xref_detached": true, + "xrefs_after_detach": 0, + "dwg": { + "path": "%TEMP%\\mech_smoke_*\\mech_smoke.dwg", + "exists": true, + "bytes": 31687, + "magic": "AC1032" + }, + "critique_issues": 0, + "critique": [] +} +``` + +The first runs found the four live defects listed under **Fixed** (the `MECH` +layer, `BLOCKREFERENCE`, the dimension `text_position`, the refused LINE read) +and one harness fault: the first call after `Documents.Add` can be refused with +`RPC_E_CALL_REJECTED` while the new document initialises, and a refused +cleanup then left the scratch document open. The smoke retries refused calls +for up to 90 s (a refused call was not executed) and tracks its documents by +the document list rather than by an assumed name, because `SaveAs` renames the +document it writes. + ## [1.5.1] — 2026-08-06 A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the diff --git a/CLAUDE.md b/CLAUDE.md index 4a66aff..3abd404 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,10 +92,14 @@ Tools are organized into sections (counts are indicative — `system_about` is a 18. Styles (10 tools, pack `core`): `dimstyle_list/create/modify/set_current` (authored ISO-25 / ANSI presets from `engineering/standards/dimstyles.py`; `dimstyle_modify` accepts only `DIM_VARIABLE_WHITELIST` names (33) and refuses out-of-range values by name before writing; `changed` lists only variables that moved, and `rerender_required: true` says a headless DIMENSION keeps its rendered block until redrawn), `textstyle_list/create/set_current` (`font_resolved: false` is reported, never refused — DXF stores only the name), `mleaderstyle_list/create` (both engines: ezdxf on `doc.mleader_styles`, live through the `ACAD_MLEADERSTYLE` dictionary — ActiveX has no MLeaderStyle *collection*, which is not an engine boundary, so no `mleaderstyle` capability key exists), and `drawing_apply_standard("iso"|"ansi")` — dimstyle + text style + units + the `mech` layer set in one call, each item reporting `created` or already present. 19. Page setup & templates (6 tools, pack `core`): `page_setup_list/apply` (ISO 216 / ANSI Y14.1 sizes from `PAPER_SIZES`; an unknown ctb is written and reported `plot_style_known: false`), `plot_style_list` (catalog + the files actually installed on a live seat), `batch_plot` (every paper layout through `drawing_export_pdf(layout=)`; `mediabox_mm` is **read back from the PDF's own `/MediaBox`**, so the sheet size is verified, not assumed), `drawing_template_list` (the five bundled templates: `iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch` — DXF headless, `.dwt` twin on COM, `scripts/build_templates.py --check` proves the DXFs reproducible), `drawing_template_save` (a `.dwt` request headlessly is refused with capability `dwt_write` naming the DXF route). `drawing_new(template=)` resolves a catalog name to the bundled file and reports `template: {name, source}`. 20. Environment (22 tools, pack `settings`): documents `document_list/activate/close` (both engines — the headless backend keeps a document registry; `document_close` refuses to discard unsaved work unless `discard=True`), layer states `layer_state_save/restore/list/delete` (portable: JSON chunks in an `ACADMCP_LAYERSTATES` XRECORD that travels with the file and **does not appear in AutoCAD's Layer States Manager** — `system_capabilities.layer_states` says so), named views `view_named_save/restore/list` (headless restore writes `$VIEWCTR/$VIEWSIZE` and reports `applied: "header_only"`), UCS `ucs_list/set/restore` (non-orthogonal axes refused with the measured angle; **tool coordinates stay WCS** — a UCS is stored for the operator, no tool interprets input in it), `system_variable_describe` (the 90-entry `SYSVAR_CATALOG`; an unknown name answers `known: false` plus nearest names, never an error; `system_set_variable` now refuses an out-of-range value for a catalogued name before writing), `drawing_properties_get/set` (custom properties on both engines through `$CUSTOMPROPERTYTAG`; the five summary fields are COM-only, capability `dwgprops`), and the live-only group `system_launch` (`live_application`), `system_preferences_get/set` (`preferences`; a whitelist of `Preferences.*` keys with ranges, read-only keys refused by name, writes report `old`/`new`), `user_pick_point/select`, `system_prompt_message` (`interactive_prompt`; a cancelled pick returns `{cancelled: true}`, a `COM_CALL_TIMEOUT` overrun reports `timed_out`). The COM paths were executed live once by `scripts/smoke_settings_com.py` (AutoCAD 2026), which also built the `.dwt` twins. +21. Mechanical parts (6 tools, pack `mech`): `mech_part_draw`, `mech_view_add`, `mech_dimension_part`, `mech_hole_pattern`, `mech_part_from_spec`, `mech_part_inspect`. One part model — a profile (`RevolvedPart` segments or a `PrismaticPart` outline) plus typed features — and one view engine that turns it into front / side / top / section / detail views with hidden lines, cut faces and ISO 129 dimensions. The model is written onto the drawing as `ACADMCP_MECH` XDATA, so `mech_view_add` can add a view months later without the caller re-describing the part. A feature that cannot be projected into a view is listed in `omitted: [{feature, reason}]` — never dropped. +22. Standard parts (3 tools, pack `mech`): `std_part_list`, `std_part_insert`, `std_feature_draw`. ISO 4014 / 4017 / 4032 / 7089 / 4762 fasteners, ISO 15 bearings with ISO 8826-1 representation, DIN 471/472 retaining-ring grooves, DIN 509 undercuts, DIN 332 centre holes and ISO 3601-2 O-ring grooves — authored tables, inserted as real blocks with attributes and `ACADMCP_MECH` XDATA, which is what makes the parts list a *read* of the drawing. A size outside a table's transcribed coverage is refused by name, never interpolated. +23. Mechanical annotation (5 tools, pack `mech`): `surface_texture` (ISO 21920-1, ISO 1302's older Ra form accepted), `weld_symbol` (ISO 2553), `centre_marks` (ISO 128-23), `section_line` (ISO 128-40 — it returns the plane `mech_view_add(kind="section")` consumes, so the label and the view cannot disagree), `hatch_material` (the ISO 128-50 map; an unknown material is refused with the list, never hatched as steel). +24. Sheet and delivery (11 tools, pack `core`): `sheet_frame` (ISO 5457 A4-A0 — 20 mm filing margin, 10 mm elsewhere on A3, zone grid, centring and trimming marks), `titleblock_apply` (ISO 7200 for every size; `titleblock_apply_iso_a3` is now a thin alias), `revision_add`, `bom_extract` (never modifies the drawing), `bom_table` (ISO 7573), `balloon_add` (ISO 6433, linked by XDATA so a rerun renumbers instead of duplicating), `data_extract` (CSV always; XLSX refused with `xlsx_write` when `openpyxl` is absent), `xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg` (COM `SaveAs` natively; headless only with the ODA File Converter, otherwise `dwg_write` refuses and names the install route). The COM paths of SECTIONS 21-24 were executed live once by `scripts/smoke_mech_com.py` (AutoCAD 2026): part, section, dimensions, an ISO 4014 block with its XDATA, the parts list read off the drawing, a linked balloon, an xref attached and detached, a real `.dwg`, and `drawing_critique(focus=None)` clean. -**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` = 55 curated drafting tools (52 with `TOOL_PACKS=core`; the five settings essentials — `drawing_apply_standard`, `dimstyle_set_current`, `textstyle_set_current`, `page_setup_apply`, `batch_plot` — are core-pack tools and stay; including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning. +**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` = 60 curated drafting tools (54 with `TOOL_PACKS=core`, which drops the three `pid_*` and the three `mech_*` lean members; the five settings essentials — `drawing_apply_standard`, `dimstyle_set_current`, `textstyle_set_current`, `page_setup_apply`, `batch_plot` — are core-pack tools and stay; including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning. -**Tool packs:** `TOOL_PACKS=all|core,pid,settings` (default `all`) advertises only the vertical packs a client uses; `core` is always on and is everything no other pack claims; `settings` is the 22 SECTION 20 environment tools, `pid` the nine P&ID tools; SECTION 18/19 are core; unknown names are ignored with a warning; a `lean` profile intersects with the enabled packs. `system_about` reports `tool_packs`. +**Tool packs:** `TOOL_PACKS=all|core,pid,settings,mech` (default `all`) advertises only the vertical packs a client uses; `core` is always on and is everything no other pack claims; `settings` is the 22 SECTION 20 environment tools, `pid` the nine P&ID tools, `mech` the 14 SECTION 21-23 mechanical tools; SECTION 18/19/24 are core; unknown names are ignored with a warning; a `lean` profile intersects with the enabled packs. `system_about` reports `tool_packs`. **Tool discovery:** `DISCOVERY_MODE=off|search` (default `off`). `search` replaces the advertised catalog with `search_tools` + `call_tool`, ranking hits over an AutoCAD command / synonym corpus (`discovery/aliases.py`) layered on fastmcp's BM25 index. @@ -107,7 +111,7 @@ Tools are organized into sections (counts are indicative — `system_about` is a **Measuring:** never read vertices back and shoelace them — that loses 28.2% of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry, and its payload states its own accuracy: `exact`, `flatten_tolerance`, `assumed_closed`, `self_intersecting`. A HATCH reports the area it *fills* (outer loops minus islands) plus `hatch_style`; on a curved hatch edge `flatten_tolerance` is not the accuracy knob, because ezdxf hands boundaries over as cubic Beziers whose ~0.028% circle error is already baked in. `boundary_trace` / `boundary_from_entities` report the same number `analysis_measure_entity` gives for the polyline they just drew — if those ever diverge, one of them is approximating and neither says so. -**Benchmark matrix:** `benchmarks/tasks_v4.py` is the current set (17 tasks; `--matrix v3` / `--matrix v2` reproduce the earlier sets). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks or the v4 `pid_roundtrip` / `page_setup_truth` — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (32 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.5.1` (the published report is `benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json`; `scripts/check_doc_numbers.py` names it in `AB_REPORT`). +**Benchmark matrix:** `benchmarks/tasks_v5.py` is the current set (18 tasks; `--matrix v4` / `v3` / `v2` reproduce the earlier sets). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks, the v4 `pid_roundtrip` / `page_setup_truth` or the v5 `mech_assembly` (a flange-coupling sheet gated on a zero-issue `drawing_critique(focus=None)` and a finalize score of at least 90) — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (39 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.5.1` (the published report is `benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json`; `scripts/check_doc_numbers.py` names it in `AB_REPORT`). ### Adding a New Tool @@ -134,10 +138,15 @@ Tools are organized into sections (counts are indicative — `system_about` is a - **Engineering layer scaffold**: `drawing_new` auto-bootstraps standard linetypes (CENTER, HIDDEN, PHANTOM) and engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK). Pass `bootstrap=False` to opt out. - **Generic contracts added in 1.6:** `block_define(name, entities, attdefs)` (typed primitives + ATTDEFs, both engines; `overwrite` replaces contents so INSERTs keep the name) and `entity_get/set_xdata` (typed by group code; 255-char strings, 16 KB per entity, enforced before writing). - **P&ID reader rules:** `pid_graph` never modifies the drawing, reads geometry from the drawing (an INSERT's real rotation/scale) rather than the catalogue, and reports `source`/`confidence` per node (catalog 1.0, xdata 0.95, heuristic 0.6, inferred 0.3) — never raised by agreement between heuristics. Read `stats.confidence_min` before trusting a foreign drawing. -- **`TOOL_PACKS=all|core,pid,settings`** advertises only the packs a client uses; `core` is always on; `system_about` reports `tool_packs`. +- **`TOOL_PACKS=all|core,pid,settings,mech`** advertises only the packs a client uses; `core` is always on; `system_about` reports `tool_packs`. - **Layer states are ours, not AutoCAD's.** `layer_state_*` stores a portable snapshot in an `ACADMCP_LAYERSTATES` XRECORD; it survives save/reopen on both engines and travels with the file, and it is not listed in AutoCAD's Layer States Manager. Said in the tool docstring, in `system_capabilities.layer_states` (`mode: "xrecord"`, reason `portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager`) and in the README. - **Page-setup evidence is the PDF.** `batch_plot` reports `mediabox_mm` parsed from the file's `/MediaBox` (`engineering/standards/pdfinfo.py`); a page setup is verified by what a printer would get, not by the setter's return value. A style or setting write reports `changed` — re-setting a value to itself is not a change. - **Nothing interprets coordinates in UCS.** `ucs_set` / `ucs_restore` store and activate a UCS for the operator's benefit; every tool input and output stays WCS (the repository rule above). +- **The part model, not primitives**: a machine part is `mech_part_draw` with a segment list or an outline plus typed features — never hand-drawn lines. The same rule as gears and keyways, for the same reason. +- **Standards tables are transcribed, never derived**: every row under `engineering/mech/standards/` carries a `SOURCE` naming standard, edition and table, and a size outside the coverage is refused by name. `engineering/mech/standards/materials.py` states which of its rows are ISO 128-50, which are ASME Y14.2M via `acadiso.pat`, and which (concrete, wood) have no mechanical standard at all. +- **Six `mech_*` critique focuses** (`mech_missing_centreline`, `mech_unhatched_section`, `mech_view_misaligned`, `mech_duplicate_dimension`, `mech_thread_unrepresented`, `mech_bom_balloon_mismatch`) run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing with no `ACADMCP_MECH` payload. `mech_view_misaligned` checks the projection axis and not the quadrant, and `mech_duplicate_dimension` catches two dimensions in the same place rather than a semantic restatement — both narrowings are in the module docstring, because the payload carries no projection angle and no engine exposes what a dimension measured. +- **Live ActiveX will not create a layer on assignment** (`entity.Layer = "X"` on a missing layer raises `Key not found`; ezdxf creates it silently). Every mechanical/annotation draw path ensures its target layers before the first entity — a new path must do the same, or it passes every headless test and dies live. +- **A diameter's text stands beyond the FIRST chord point** on both engines (the ActiveX `AddDimDiametric` rule, measured); `dimension_diameter(x1, y1, x2, y2)` annotates on the `(x1, y1)` side. - **Production drawings**: For real engineering output, use the `engineering/` package primitives via the `gear_*` / `keyway_*` / `titleblock_*` MCP tools — do NOT hand-draw teeth/keyways/sections with raw `entity_create_*` calls. Always end with `drawing_finalize` for the 8-step validator. ### Premium Drawing Rules diff --git a/README.md b/README.md index 1471067..63ba5c3 100644 --- a/README.md +++ b/README.md @@ -27,22 +27,23 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3976 collected tests. +> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3990 collected tests. > 229 is the **registered** count; a default install advertises 224 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists -**A big MCP server is expensive to be connected to.** The full catalog costs a client **59,315 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**. +**A big MCP server is expensive to be connected to.** The full catalog costs a client **72,163 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**. -**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **193 AutoCAD command names and 1019 synonym phrases** covering all 215 tools. A test refuses to let a tool exist without one. +**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **197 AutoCAD command names and 1158 synonym phrases** covering all 229 tools. A test refuses to let a tool exist without one. | Advertised surface | Tools seen | Idle cost | |---|---:|---:| -| `TOOL_PROFILE=full` (default) | 199 | 59,315 tokens | -| `TOOL_PACKS=core,settings` (full profile) | 190 | 55,593 tokens | -| `TOOL_PACKS=core` (full profile) | 168 | 48,825 tokens | -| `TOOL_PROFILE=lean` | 55 | 16,360 tokens | +| `TOOL_PROFILE=full` (default) | 224 | 72,163 tokens | +| `TOOL_PACKS=core,settings` (full profile) | 201 | 60,727 tokens | +| `TOOL_PACKS=core,mech` (full profile) | 193 | 61,673 tokens | +| `TOOL_PACKS=core` (full profile) | 179 | 53,959 tokens | +| `TOOL_PROFILE=lean` | 60 | 19,484 tokens | | `DISCOVERY_MODE=search` | 2 | **356 tokens** | > [!NOTE] @@ -101,9 +102,11 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Geometry | lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits | | Annotation | ISO 129 toleranced dimensions, ISO 286 fits (`fit="H7"`), TABLE, MLEADER, GD&T frames and datums (ISO 1101) | | Engineering generators | involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 title block | +| Mechanical parts | one part model (segments or outline + typed features) and one view engine: front / side / top / section / detail with hidden lines, ISO 128-50 cut faces and ISO 129 dimensions; ISO 4014/4017/4032/7089/4762 fasteners, ISO 15 bearings, DIN 471/472, DIN 509, DIN 332 and ISO 3601-2 as real blocks with attributes; ISO 21920-1 surface texture, ISO 2553 welds, ISO 128-40 section lines | +| Sheet & delivery | ISO 5457 frames A4-A0 with zones and trim marks, ISO 7200 title blocks for every size, revision blocks with clouds, ISO 7573 parts lists with ISO 6433 balloons linked by XDATA, CSV/XLSX extraction, xrefs and images on both engines, real `.dwg` on a live seat | | P&ID | catalogue blocks with ports and tags (ISO 10628-2 / ISA-5.1), port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reads any P&ID back with confidence, instrument index / line list / equipment list, `pid_from_spec` one-call sheets | | Styles & standards | ISO-25 / ANSI dimension styles, ISOCP / ROMANS text styles, ISO / ANSI leader styles from authored presets; `drawing_apply_standard("iso")` sets all of it plus units and the `mech` layers in one call; `changed` reports only what moved | -| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 199-tool advertised catalog | +| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 224-tool advertised catalog | | Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools | | Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` | | Page setup & templates | ISO 216 / ANSI Y14.1 sheets, ctb catalog, `batch_plot` verified by the PDF's own `/MediaBox`, five bundled templates built by the server's own tools and pinned reproducible (`drawing_new(template="iso_a3_mech")`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`), save any drawing as a template (`.dwt` on live AutoCAD, `dwt_write` refused headlessly) | @@ -157,28 +160,29 @@ Self-measurement, produced by scripts in [`benchmarks/`](https://github.com/U-C4 ### Correctness — every release re-proves itself -32 deterministic headless checks against the previous tag and the current tree, each in its own subprocess so a hard crash counts as a miss rather than killing the run. +39 deterministic headless checks against the previous tag and the current tree, each in its own subprocess so a hard crash counts as a miss rather than killing the run. | Version | Checks passing | Pass rate | Fixed | Regressed | |---|---:|---:|---:|---:| -| v1.5.1 *(baseline)* | 26 / 32 | 81.2 % | — | — | -| **v1.6.0-dev** *(this branch)* | **32 / 32** | **100 %** | 6 | **0** | +| v1.5.1 *(baseline)* | 26 / 39 | 66.7 % | — | — | +| **v1.6.0-dev** *(this branch)* | **39 / 39** | **100 %** | 13 | **0** | -The six fixed rows are new capability (`miss → pass`): the three P&ID checks of track A and, from track E, `settings_dimstyle_iso25_values` (the ISO-25 preset lands in the DIMSTYLE table with ISO 129-1's numbers), `settings_layer_state_roundtrip` (save → change → restore puts the layer table back, and the state survives save/reopen because it is an XRECORD in the file) and `settings_pdf_mediabox_a3` (the plotted PDF's own `/MediaBox` reads 420 × 297). The 26 checks v1.5.1 was gated on all still pass. Against the older `v1.4.0` baseline the same 26 reported **21 / 26 → 26 / 26, five fixed, zero regressed** — two of those were repaired defects (`fail → pass`), the diameter and radius callouts, which measured the leader as geometry and dimensioned a 40 mm bore as 60 at default settings. +The thirteen fixed rows are new capability (`miss → pass`). Tracks B and G add seven: `mech_part_roundtrip` (a part read back out of its own `ACADMCP_MECH` XDATA equals the part drawn), `mech_section_hatch_area` (a 60 x ⌀40 sleeve with a ⌀20 bore cuts 1200 mm² exactly, measured out of the drawing by `analysis_measure_entity`), `mech_iso286_on_dimension` (40 H7 is +0.025 / 0 and survives the layout), `mech_thread_unrepresented_is_caught` (the ISO 6410 focus fires on a thread drawn as a plain circle — a gate that never fires is not a gate), `std_part_iso4014_m12` (s = 18, k = 7.5, read by value), `sheet_frame_iso5457_a3` (the frame runs 20,10 to 410,287 inside the 420 × 297 sheet) and `bom_balloon_link` (two identical bolts are one row of quantity 2, and its balloon carries the row's item number). The other six are the three P&ID checks of track A and, from track E, `settings_dimstyle_iso25_values` (the ISO-25 preset lands in the DIMSTYLE table with ISO 129-1's numbers), `settings_layer_state_roundtrip` (save → change → restore puts the layer table back, and the state survives save/reopen because it is an XRECORD in the file) and `settings_pdf_mediabox_a3` (the plotted PDF's own `/MediaBox` reads 420 × 297). The 26 checks v1.5.1 was gated on all still pass. Against the older `v1.4.0` baseline the same 26 reported **21 / 26 → 26 / 26, five fixed, zero regressed** — two of those were repaired defects (`fail → pass`), the diameter and radius callouts, which measured the leader as geometry and dimensioned a 40 mm bore as 60 at default settings. -### The task matrix — six tasks that can fail +### The task matrix — tasks that can fail -An earlier matrix scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five were added in 1.5 because they *can* fail, and three did while being written; 1.6 adds two more — one the P&ID reader can fail on its own, one where the PDF file, not the setter, is the witness. +An earlier matrix scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five were added in 1.5 because they *can* fail, and three did while being written; 1.6 adds three more — one the P&ID reader can fail on its own, one where the PDF file, not the setter, is the witness, and one where a whole mechanical sheet has to come out clean. | Task | Verified against | |---|---| | `tool_discovery` | six AutoCAD command names, each ranking #1 | -| `token_budget` | 59,315 → 356 tokens, against a ceiling fixed in advance | +| `token_budget` | 72,163 → 356 tokens, against a ceiling fixed in advance | | `hatch_islands` | 300 filled with the island, 400 ignoring it | | `selection_filter` | window 1, crossing 2, bounding box 3, polygon 1 | | `measure_from_handle` | 139.2699 against the 100.0 a vertex shoelace gives | | `pid_roundtrip` | the example sheet drawn by `pid_from_spec`, read back by `pid_graph`, which never sees the spec: 5 nodes, 4 edges, 0 dangling, `confidence_min` 1.0, `FIC-101` wired to `FCV-101` | | `page_setup_truth` | ANSI B then ISO A3 landscape applied to Layout1, each plotted through `batch_plot` and read back from its PDF's `/MediaBox`: 432 × 279 then 420 × 297 mm, not the setter's return value (a fresh layout is already A3, so the B sheet is what a no-op setter cannot fake) | +| `mech_assembly` | a flange-coupling A3 sheet — hub with a full section, flange with its bolt-circle end view, two ISO 4014 bolts with ISO 4032 nuts, an ISO 7573 parts list read off the drawing, linked balloons, ISO 5457 frame, ISO 7200 title block — gated on `drawing_critique(focus=None)` returning **zero** issues and a finalize score of at least **90**; a test drops one balloon and watches the gate fail | ### Headless performance diff --git a/backends/com_backend.py b/backends/com_backend.py index fdef6b2..b680b97 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1346,6 +1346,18 @@ def _entity_info(entity) -> EntityInfo: geometry_bbox = _com_geometry_bbox(entity) if geometry_bbox is not None: props["geometry_bbox"] = geometry_bbox + elif "Dimension" in obj_name: + # ezdxf parity: where the dimension's text stands. Without it the + # dim_overlap critique fell back to the bounding-box centre, which + # for a diameter dimension is the circle's centre - so an outside + # diameter and its bore, whose texts stand 15 mm apart, read as + # "0.00 mm apart" (measured on AutoCAD 2026 by + # scripts/smoke_mech_com.py; TextPosition read live on + # AcDbDiametricDimension, WCS). ModelSpace.Item hands the entity + # over narrowed to IAcadEntity, which has no TextPosition - measured + # AttributeError - so it is read through the concrete class. + text = _com_unnarrow(entity).TextPosition + props["text_position"] = [text[0], text[1]] except Exception as exc: log.debug("Type-specific entity properties extraction failed: %s", exc) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 5e6335e..a7efa4e 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -5550,7 +5550,15 @@ class EzdxfBackend(AutoCADBackend): cx = (x1f + x2f) / 2 cy = (y1f + y2f) / 2 radius = math.sqrt((x2f - x1f) ** 2 + (y2f - y1f) ** 2) / 2 - angle_rad = math.atan2(y2f - y1f, x2f - x1f) + # The text stands `leader_length` beyond the FIRST chord point, the + # ActiveX rule: AddDimDiametric(ChordPoint, FarChordPoint, + # LeaderLength) measured on AutoCAD 2026 put a 50 mm diameter drawn + # from (x, 170) to (x, 220) with its text at (x, 160). This engine + # used to put it beyond the second point, so the same call annotated + # opposite sides of a part on the two engines - and the mechanical + # drawer's front-view diameters landed on their own chain dimensions + # headlessly while clearing them live. + angle_rad = math.atan2(y1f - y2f, x1f - x2f) leader = float(leader_length) # The measured size comes from `radius`; `location` only places the # text. This used to pass centre + (radius + leader) as `mpoint`, diff --git a/benchmarks/README.md b/benchmarks/README.md index 03efb0c..4eeeb23 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -41,13 +41,13 @@ Unsupported tasks remain in the fixed-matrix denominator with score zero, preventing partial implementations from receiving an inflated score. ```bash -python -m benchmarks.run_competitors --list --matrix v4 -python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v4 --json +python -m benchmarks.run_competitors --list --matrix v5 +python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v5 --json python -m benchmarks.run_competitors --task table_mleader --task hatch_islands --json ``` -The release-machine ezdxf self-check is **17/17 (100.0)** on the v4 matrix -(`--matrix v3` / `--matrix v2` reproduce the earlier sets). +The release-machine ezdxf self-check is **18/18 (100.0)** on the v5 matrix +(`--matrix v4` / `--matrix v3` / `--matrix v2` reproduce the earlier sets). Repository stars and raw tool counts do not contribute to the score. Adapter registration lives in `competitors.yaml`. @@ -100,6 +100,23 @@ fail on their own: The competitor reports carry no result for either, for the same reason they carry none for the v3 five. `--matrix v3` reproduces the v1.5 set exactly. +### Matrix v5 (v1.6) — the mechanical sheet + +`tasks_v5.py` keeps the v4 seventeen unchanged and adds roadmap criterion 3: + +| Task | Category | Verified against | +|---|---|---| +| `mech_assembly` | mech | a flange-coupling A3 sheet built through the code the tools call — `apply_titleblock` (ISO 5457 frame with zones and marks, ISO 7200 title block), a hub drawn by `draw_part` with a full section by `add_view` and chain dimensions by `dimension_part`, a flange with its bolt-circle end view, two ISO 4014 M12x60 bolts and two ISO 4032 M12 nuts by `insert_std_part`, the ISO 7573 parts list *read off the drawing* by `extract_records` and drawn by `draw_bom_table`, and one balloon per row linked to that row's inserts by `add_balloon`. The sheet is saved, then gated on two numbers that cannot be negotiated: `run_critique(focus=None)` returns **zero** issues and `combine(validator, critique)` scores at least **90**. The focuses that fired come back in the metrics, and `tests/test_benchmark_v5.py` drops one balloon to prove the gate fails and names `mech_bom_balloon_mismatch` | + +Nothing in the task tidies up after the tools: the view engine draws the ISO +128-23 centre lines of every circle it emits, so the sheet is clean because it +was drawn clean. Building it found four defects that every headless test had +missed — the headless diameter text on the wrong side of the chord, the +validator reading a fastener designation (`M12x60`) as a fake dimension, and on +the live seat a missing `MECH` layer and a parts list that could not see a +`BLOCKREFERENCE`; each is fixed with a test. The competitor reports carry no +result for it. + ## Live competitor lane (v1.4) Two competitor adapters now execute the exact same task matrix, black-box over @@ -217,15 +234,29 @@ python benchmarks/compare_versions.py --json results.json ### Result — this branch (1.6.0-dev) vs v1.5.1 (release gate) -32 checks, ezdxf backend, one subprocess per check. Machine-readable report: +39 checks, ezdxf backend, one subprocess per check. Machine-readable report: [`results/published/ab-v1.5.1-vs-v1.6.0-dev.json`](results/published/ab-v1.5.1-vs-v1.6.0-dev.json). | Version | Checks passing | Pass rate | Fixed | Regressed | |---------|----------------|-----------|-------|-----------| -| **v1.5.1** (baseline) | 26 / 32 | 81.2 % | — | — | -| **v1.6.0-dev** (this branch) | 32 / 32 | 100 % | 6 | **0** | +| **v1.5.1** (baseline) | 26 / 39 | 66.7 % | — | — | +| **v1.6.0-dev** (this branch) | 39 / 39 | 100 % | 13 | **0** | -The six are all `miss → pass` — v1.5.1 has none of the methods: the three +The thirteen are all `miss → pass` — v1.5.1 has none of the methods. Seven +are tracks B and G: `mech_part_roundtrip` (a part read back from its own +`ACADMCP_MECH` XDATA equals the part drawn), `mech_section_hatch_area` (a +sleeve 60 long, ⌀40 outside, ⌀20 bore: a full section cuts two 60 × 10 faces, +1200 mm² exactly, measured out of the drawing by `entity_measure` — never from +the code that drew it), `mech_iso286_on_dimension` (ISO 286-1: 40 H7 is ++0.025 / 0, and the fit survives `layout_dimensions`), +`mech_thread_unrepresented_is_caught` (the ISO 6410 focus fires on a thread +drawn as a plain circle and stops firing once the thin ¾ arc is there — the +mechanical twin of `dim_overlap_critique_fires`), `std_part_iso4014_m12` (ISO +4014:2011 table 1, M12: s = 18, k = 7.5, matched by value; the inserted block +carries its designation back), `sheet_frame_iso5457_a3` (the frame runs 20,10 +to 410,287 inside the 420 × 297 trimmed sheet) and `bom_balloon_link` (two +identical bolts collapse into one row of quantity 2 with a stable item number, +and that row's balloon carries it). The other six are the three P&ID checks track A added (`pid_block_define_attdef_roundtrip`, `pid_tag_parse_fic`, `pid_graph_edge_count`) and the three settings checks of track E (`settings_dimstyle_iso25_values`: the ISO-25 preset reaches the @@ -234,7 +265,7 @@ through the lister; `settings_layer_state_roundtrip`: save → change → restor puts the layer table back and the state survives save/reopen as an XRECORD in the file; `settings_pdf_mediabox_a3`: the plotted PDF's own `/MediaBox` reads 420 × 297 mm). Every one of the 26 checks it was released on still passes, so -the two tracks added capability without moving a number it had already earned. +the four tracks added capability without moving a number it had already earned. ### Result — v1.5.1 vs v1.5.0 diff --git a/benchmarks/adapters/autocad_mcp_pro.py b/benchmarks/adapters/autocad_mcp_pro.py index 7681965..85bc442 100644 --- a/benchmarks/adapters/autocad_mcp_pro.py +++ b/benchmarks/adapters/autocad_mcp_pro.py @@ -1,4 +1,4 @@ -"""Reference adapter for this repository, covering the v4 task matrix. +"""Reference adapter for this repository, covering the v5 task matrix. Every task verifies against a value worked out independently of the code under test — a closed-form area, a count of entities placed on purpose, a token @@ -12,7 +12,7 @@ import math from pathlib import Path from benchmarks.adapters.base import BenchmarkAdapter, TaskResult -from benchmarks.tasks_v4 import TaskSpec +from benchmarks.tasks_v5 import TaskSpec from engineering.delivery import deliver_drawing from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes from engineering.refiner import refine_drawing @@ -514,6 +514,174 @@ class AutoCADMCPProAdapter(BenchmarkAdapter): [str(row_before["path"]), str(row["path"])], ) + async def _task_mech_assembly(self): + """A flange-coupling sheet built from the part model and the sheet standard. + + Roadmap criterion 3. Two parts (a hub with a bore and a flange), a full + section through the hub, four standard fasteners with their parts-list + rows and balloons, an ISO 5457 A3 frame and an ISO 7200 title block. + Every step goes through the code the tools call: the frame and title + block through ``apply_titleblock``, the parts list is *read* off the + drawing by ``extract_records`` and drawn by ``draw_bom_table``, and each + balloon is linked to its row's inserts by ``add_balloon``. + + The gate is two numbers, and both are asserted rather than reported: + ``run_critique(focus=None)`` must return zero issues, and the score the + finalize gate computes - ``combine(validator.summary, critique)`` over + the saved sheet - must be at least 90. A regression in either fails the + task; the focuses that fired come back in the metrics so the failure + names itself. + + No step here repairs what the critique would flag: the view engine + draws the ISO 128-23 centre lines of every circle it emits (the end + view's bore and bolt holes included), so the sheet is clean because + the tools drew it clean, not because the benchmark tidied up after them. + """ + from engineering import DrawingValidator + from engineering.critique import run_critique + from engineering.layers import apply_layer_set + from engineering.mech.draw import add_view, dimension_part, draw_part + from engineering.mech.part import build_part + from engineering.mech.stdparts import insert_std_part + from engineering.scoring import combine + from engineering.sheet.bom import ( + add_balloon, + draw_bom_table, + extract_records, + rows_from_records, + ) + from engineering.sheet.titleblock import ( + TB_HEIGHT, + TitleBlockMetadata, + apply_titleblock, + titleblock_origin, + ) + + await apply_layer_set(self.backend, "mech") + await apply_titleblock( + self.backend, + size="A3", + metadata=TitleBlockMetadata( + title="FLANGE COUPLING", + drawing_no="MA-001", + drawn_by="autocad-mcp-pro", + scale="1:1", + material="S235JR", + ), + projection="first", + frame=True, + zones=True, + marks=True, + ) + + hub = build_part( + { + "kind": "revolved", + "name": "HUB", + "material": "steel", + "segments": [ + {"length": 40.0, "d_outer": 40.0, "d_inner": 20.0}, + {"length": 15.0, "d_outer": 90.0, "d_inner": 20.0}, + ], + } + ) + flange = build_part( + { + "kind": "revolved", + "name": "FLANGE", + "material": "cast_iron", + "segments": [{"length": 15.0, "d_outer": 90.0, "d_inner": 40.0}], + # Four M12 clearance holes (ISO 273 medium, 13.5) on a 65 PCD. + "features": [ + { + "kind": "hole_circle", + "id": "bolts", + "x": 7.5, + "pcd": 65.0, + "count": 4, + "diameter": 13.5, + "start_angle": 45.0, + } + ], + } + ) + # A3 landscape: the frame runs (20, 10)-(410, 287) and the title block + # fills its lower-right corner, 180 x 60. First angle throughout: the + # hub's front view sits at x = 125 so its section lands left of it + # inside the frame; the flange's front view stands at x = 345 so its + # end view (the bolt circle) lands between the two parts; the + # fasteners take the free band below, left of the title block. + drawn_hub = await draw_part( + self.backend, hub, at=(125.0, 160.0), views=("front",), dimension=False + ) + # A full longitudinal section along the hub's axis (part-local frame). + await add_view( + self.backend, + drawn_hub["part_id"], + "section", + plane={"p1": [0.0, 0.0], "p2": [55.0, 0.0], "label": "A"}, + style="full", + ) + await dimension_part(self.backend, drawn_hub["part_id"], style="chain") + await draw_part( + self.backend, flange, at=(345.0, 160.0), views=("front", "side"), dimension=True + ) + + # Two bolt-and-nut pairs: each nut sits on the thread of its bolt. + stations = (60.0, 110.0, 160.0, 210.0) + fasteners = [] + for index, x in enumerate(stations): + designation = "ISO 4014 - M12x60" if index % 2 == 0 else "ISO 4032 - M12" + fasteners.append( + await insert_std_part(self.backend, designation, at=(x, 90.0), view="side") + ) + + # The parts list is a read of the drawing, not a list typed beside it. + records = await extract_records(self.backend) + rows = rows_from_records(records) + tb_x, tb_y = titleblock_origin("A3") + await draw_bom_table(self.backend, rows, at=(tb_x, tb_y + TB_HEIGHT)) + station_of = { + str(result.get("handle")): x for result, x in zip(fasteners, stations, strict=True) + } + for row in rows: + targets = [str(handle) for handle in row.get("handles") or ()] + x = station_of.get(targets[0], stations[0]) if targets else stations[0] + await add_balloon( + self.backend, + item=int(row["item"]), + at=(x, 135.0), + leader_to=(x, 95.0), + targets=targets, + ) + + # drawing_finalize saves before it validates; so does the benchmark, + # or the validator's not_saved/no_path findings would grade the + # harness instead of the sheet. + sheet = self.artifact_dir / "mech_assembly.dxf" + await self.backend.drawing_save_as(str(sheet)) + issues = await run_critique(self.backend, None) + validation = await DrawingValidator().run(self.backend) + summary = {"error": 0, "warning": 0, "info": 0} + for issue in issues: + summary[issue.severity] = summary.get(issue.severity, 0) + 1 + score = combine(validation.summary, summary) + passed = len(issues) == 0 and float(score["score"]) >= 90.0 + return ( + passed, + { + "critique_issues": len(issues), + "critique_focuses": sorted({issue.focus for issue in issues}), + "score": float(score["score"]), + "grade": score["grade"], + "invalidity_ratio": score["invalidity_ratio"], + "parts_list_rows": len(rows), + "fasteners": len(fasteners), + "validator_findings": sorted(f.code for f in validation.findings), + }, + [str(sheet)], + ) + async def _task_auditable_delivery(self): await ensure_engineering_layers(self.backend) await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY") diff --git a/benchmarks/correctness_suite.py b/benchmarks/correctness_suite.py index d22cf6b..d8b3875 100644 --- a/benchmarks/correctness_suite.py +++ b/benchmarks/correctness_suite.py @@ -417,6 +417,231 @@ async def settings_pdf_mediabox_a3(): ) +# ── Mechanical parts and the sheet (tracks B + G) ─────────────────────────── + + +def _field(row: dict, *names): + """A row's value under whichever of these names it carries. + + The parts-list column spelling belongs to the sheet module; what this + suite pins is the numbers, so it reads by meaning rather than by name. + """ + for name in names: + if name in row: + return row[name] + return None + + +def _inserted_handle(result: dict): + return result.get("handle") or (result.get("handles") or [None])[0] + + +async def mech_part_roundtrip(): + """Draw a part, read it back from its own ACADMCP_MECH XDATA, compare. + + Segments and material only: a feature's parameter spelling belongs to + `engineering/mech/features.py` and its own tests, and a check that guessed + at it would fail for the wrong reason. + """ + from engineering.mech.draw import draw_part, read_part + from engineering.mech.part import build_part, part_to_dict + + b = await _b() + spec = { + "kind": "revolved", + "name": "SHAFT", + "material": "steel", + "segments": [ + {"length": 40.0, "d_outer": 30.0}, + {"length": 20.0, "d_outer": 50.0, "d_inner": 10.0}, + ], + } + part = build_part(spec) + drawn = await draw_part(b, part, at=(0.0, 0.0), views=("front",), dimension=False) + back = await read_part(b, drawn["part_id"]) + return part_to_dict(build_part(back["part"])) == part_to_dict(part) + + +async def mech_section_hatch_area(): + """The hatched cut area of a full section, measured against a hand number. + + A plain sleeve: length 60, outside diameter 40, bore 20. A full + longitudinal section cuts two faces, each 60 x 10, so the hatch fills + 60 * (40 - 20) = 1200 mm^2 exactly. The number is read out of the drawing + through entity_measure (the tool's analysis_measure_entity), never from the + code that drew it — which is why it cannot be faked. + """ + from engineering.mech.draw import add_view, draw_part + from engineering.mech.part import build_part + + b = await _b() + part = build_part( + { + "kind": "revolved", + "name": "SLEEVE", + "material": "steel", + "segments": [{"length": 60.0, "d_outer": 40.0, "d_inner": 20.0}], + } + ) + drawn = await draw_part(b, part, at=(0.0, 0.0), views=("front",), dimension=False) + await add_view( + b, + drawn["part_id"], + "section", + plane={"p1": [0.0, 0.0], "p2": [60.0, 0.0], "label": "A"}, + style="full", + ) + total = 0.0 + for ent in await b.entity_list(type_filter="HATCH", limit=500): + total += float((await b.entity_measure(ent.handle))["area"]) + return abs(total - 1200.0) < 0.01 + + +async def mech_iso286_on_dimension(): + """A 40 H7 bore dimension carries ISO 286's +0.025 / 0 through the layout. + + ISO 286-1 table 1: the 30-50 mm step has IT7 = 25 um, and H fixes the lower + deviation at 0. The fit has to survive `layout_dimensions`, or the number + is right in the table and wrong on the sheet. + """ + from engineering.fits import fit_lookup + from engineering.mech.dimension import layout_dimensions + from engineering.mech.primitives import Circle, DimIntent + from engineering.mech.views import View + + deviation = fit_lookup("H7", 40.0) + if abs(deviation.upper_mm - 0.025) > 1e-9 or abs(deviation.lower_mm) > 1e-9: + return False + intent = DimIntent("diameter", (-20.0, 0.0), (20.0, 0.0), fit="H7", feature="bore") + view = View( + kind="front", + prims=(Circle((0.0, 0.0), 20.0),), + dims=(intent,), + omitted=(), + bbox=(-20.0, -20.0, 20.0, 20.0), + ) + placed = layout_dimensions(view, [intent]) + return len(placed) == 1 and placed[0].fit == "H7" and placed[0].feature == "bore" + + +async def mech_thread_unrepresented_is_caught(): + """The ISO 6410 focus fires on a thread drawn as a plain circle. + + A quality gate that never fires is not a gate; this is the mechanical twin + of `dim_overlap_critique_fires`. + """ + from engineering.mech.critique import build_index, issues_for + from engineering.mech.xdata import APP_ID, to_values + + b = await _b() + payload = { + "v": 1, + "kind": "part", + "id": "P1", + "view": "side", + "part": { + "name": "STUD", + "material": "steel", + "segments": [{"length": 60.0, "d_outer": 20.0}], + "features": [{"kind": "thread", "id": "t1", "designation": "M20x1.5"}], + }, + } + anchor = await b.entity_create_circle(0.0, 0.0, 0.5, layer="0") + await b.entity_set_xdata(anchor.handle, APP_ID, to_values(payload)) + await b.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY") + before = issues_for("mech_thread_unrepresented", await build_index(b)) + await b.entity_create_arc(0.0, 0.0, 8.0, 0.0, 270.0, layer="GEOMETRY") + after = issues_for("mech_thread_unrepresented", await build_index(b)) + return len(before) == 1 and after == [] + + +async def sheet_frame_iso5457_a3(): + """ISO 5457 A3: 420 x 297 trimmed, a 20 mm filing margin on the left and + 10 mm on the other three edges, so the frame runs (20, 10) to (410, 287) + and the drawing space is 390 x 277. The trimmed sheet edge is drawn too + (ISO 5457 4.1), so the outermost extent is the sheet, 0,0 to 420,297 - + and nothing may reach past it.""" + from engineering.mech.primitives import points_of + from engineering.sheet.frames import SHEETS, frame_prims + + if tuple(float(v) for v in SHEETS["A3"]) != (297.0, 420.0): + return False + prims = frame_prims("A3", orientation="landscape", zones=False, marks=False) + points = { + (round(float(x), 6), round(float(y), 6)) for prim in prims for x, y in points_of(prim) + } + frame = {(20.0, 10.0), (410.0, 10.0), (410.0, 287.0), (20.0, 287.0)} + sheet = {(0.0, 0.0), (420.0, 0.0), (420.0, 297.0), (0.0, 297.0)} + xs = [x for x, _y in points] + ys = [y for _x, y in points] + extent = (min(xs), min(ys), max(xs), max(ys)) + return frame <= points and sheet <= points and extent == (0.0, 0.0, 420.0, 297.0) + + +async def bom_balloon_link(): + """Two identical bolts collapse into one row of quantity 2, the item number + is stable across a rerun, and the balloon drawn for that row carries it.""" + from engineering.mech.stdparts import insert_std_part + from engineering.mech.xdata import APP_ID, decode + from engineering.sheet.bom import balloon_prims, rows_from_records + + b = await _b() + records = [] + for x in (0.0, 60.0): + result = await insert_std_part(b, "ISO 4014 - M12x60", at=(x, 0.0), view="side") + raw = await b.entity_get_xdata(_inserted_handle(result), APP_ID) + records.append(decode(raw["xdata"][APP_ID])) + rows = rows_from_records(records) + again = rows_from_records(records) + if len(rows) != 1 or len(again) != 1: + return False + item = _field(rows[0], "item", "no", "pos") + if item is None or item != _field(again[0], "item", "no", "pos"): + return False + if int(_field(rows[0], "qty", "quantity") or 0) != 2: + return False + texts = [ + p.text for p in balloon_prims(int(item), (0.0, 40.0), (0.0, 5.0)) if hasattr(p, "text") + ] + return texts == [str(int(item))] + + +async def std_part_iso4014_m12(): + """ISO 4014's M12 row and the block it becomes. + + ISO 4014:2011 table 1, M12: width across flats s = 18 mm, head height + k = 7.5 mm. The row is matched by value rather than by column name — the + spelling is the catalogue's business, the numbers are the standard's — and + the inserted block has to carry its designation back in ACADMCP_MECH. + The catalogue nests a row's sizes one level down (``dims``), so the + values are read one level deep. + """ + from engineering.mech.stdparts import catalogue, insert_std_part + from engineering.mech.xdata import APP_ID, decode + + rows = [row for row in catalogue("M12") if any("4014" in str(value) for value in row.values())] + if not rows: + return False + values = [ + inner + for row in rows + for value in row.values() + for inner in (value.values() if isinstance(value, dict) else (value,)) + ] + numbers = { + round(float(value), 3) + for value in values + if isinstance(value, (int, float)) and not isinstance(value, bool) + } + if not {18.0, 7.5} <= numbers: + return False + b = await _b() + result = await insert_std_part(b, "ISO 4014 - M12x60", at=(0.0, 0.0), view="side") + raw = await b.entity_get_xdata(_inserted_handle(result), APP_ID) + payload = decode(raw["xdata"][APP_ID]) + return payload["kind"] == "std_part" and "M12" in str(payload["designation"]) + + CHECKS = { "core_line_length": (core_line_length, "Core"), "core_circle_radius": (core_circle_radius, "Core"), @@ -450,6 +675,13 @@ CHECKS = { "settings_dimstyle_iso25_values": (settings_dimstyle_iso25_values, "Settings"), "settings_layer_state_roundtrip": (settings_layer_state_roundtrip, "Settings"), "settings_pdf_mediabox_a3": (settings_pdf_mediabox_a3, "Settings"), + "mech_part_roundtrip": (mech_part_roundtrip, "Mechanical"), + "mech_section_hatch_area": (mech_section_hatch_area, "Mechanical"), + "mech_iso286_on_dimension": (mech_iso286_on_dimension, "Mechanical"), + "mech_thread_unrepresented_is_caught": (mech_thread_unrepresented_is_caught, "Mechanical"), + "std_part_iso4014_m12": (std_part_iso4014_m12, "Mechanical"), + "sheet_frame_iso5457_a3": (sheet_frame_iso5457_a3, "Sheet"), + "bom_balloon_link": (bom_balloon_link, "Sheet"), } diff --git a/benchmarks/render_matrix_chart.py b/benchmarks/render_matrix_chart.py index ebd975d..7513d33 100644 --- a/benchmarks/render_matrix_chart.py +++ b/benchmarks/render_matrix_chart.py @@ -13,7 +13,7 @@ import json from pathlib import Path from typing import Any -from benchmarks.tasks_v4 import TASKS_V4 +from benchmarks.tasks_v5 import TASKS_V5 ROOT = Path(__file__).resolve().parents[1] DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published" @@ -66,8 +66,8 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None: matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-taskmatrix-v1" - task_ids = [task.task_id for task in TASKS_V4] - task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V4] + task_ids = [task.task_id for task in TASKS_V5] + task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V5] servers = [report["adapter"] for report in reports] status_by_server = { report["adapter"]: {item["task_id"]: item["status"] for item in report["results"]} diff --git a/benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json b/benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json index ace10dc..7ad938c 100644 --- a/benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json +++ b/benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json @@ -1,12 +1,12 @@ { "summary": { "baseline_ref": "v1.5.1", - "total": 32, + "total": 39, "old_pass": 26, - "new_pass": 32, - "old_pct": 81.2, + "new_pass": 39, + "old_pct": 66.7, "new_pct": 100.0, - "fixed": 6, + "fixed": 13, "regressed": 0 }, "rows": [ @@ -201,6 +201,48 @@ "category": "Settings", "old": "miss", "new": "pass" + }, + { + "check": "mech_part_roundtrip", + "category": "Mechanical", + "old": "miss", + "new": "pass" + }, + { + "check": "mech_section_hatch_area", + "category": "Mechanical", + "old": "miss", + "new": "pass" + }, + { + "check": "mech_iso286_on_dimension", + "category": "Mechanical", + "old": "miss", + "new": "pass" + }, + { + "check": "mech_thread_unrepresented_is_caught", + "category": "Mechanical", + "old": "miss", + "new": "pass" + }, + { + "check": "std_part_iso4014_m12", + "category": "Mechanical", + "old": "miss", + "new": "pass" + }, + { + "check": "sheet_frame_iso5457_a3", + "category": "Sheet", + "old": "miss", + "new": "pass" + }, + { + "check": "bom_balloon_link", + "category": "Sheet", + "old": "miss", + "new": "pass" } ] } \ No newline at end of file diff --git a/benchmarks/run_competitors.py b/benchmarks/run_competitors.py index ed3256a..ee25a8c 100644 --- a/benchmarks/run_competitors.py +++ b/benchmarks/run_competitors.py @@ -16,7 +16,7 @@ from pathlib import Path from backends.capability import UnsupportedCapabilityError from benchmarks.adapters.base import BenchmarkAdapter, TaskResult -from benchmarks.tasks_v4 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id +from benchmarks.tasks_v5 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id def _load_registry() -> dict[str, dict]: diff --git a/benchmarks/tasks_v5.py b/benchmarks/tasks_v5.py new file mode 100644 index 0000000..e986f49 --- /dev/null +++ b/benchmarks/tasks_v5.py @@ -0,0 +1,50 @@ +"""The v5 task matrix: v4 plus what tracks B and G added. + +``mech_assembly`` (tracks B + G) is roadmap criterion 3: a flange-coupling +sheet built from the tools — two parts with a section, a bolt circle of ISO +4014 bolts and ISO 4032 nuts, ISO 129 dimensions, an ISO 5457 A3 frame, an +ISO 7200 title block and an ISO 7573 parts list with balloons. The gate is two +numbers, not an impression: ``drawing_critique(focus=None)`` returns zero +issues and the finalize score is at least 90. Either one slipping fails the +task outright. + +The published competitor reports were never asked this question; the chart +shows ``not_run`` for them, not zero. +""" + +from __future__ import annotations + +from benchmarks.tasks_v2 import TASKS_V2 +from benchmarks.tasks_v3 import TASKS_V3 +from benchmarks.tasks_v4 import TASKS_V4, TaskSpec + +#: Added in v1.6.0 by tracks B + G. +NEW_TASKS_V5: tuple[TaskSpec, ...] = ( + TaskSpec( + "mech_assembly", + "mech", + "Draw a flange-coupling sheet from the part model and the sheet standard, " + "with a clean critique and a finalize score of at least 90", + 1.0, + ), +) + +TASKS_V5: tuple[TaskSpec, ...] = TASKS_V4 + NEW_TASKS_V5 + +#: Runner-selectable task sets. v2, v3 and v4 stay addressable so an earlier +#: report can be reproduced exactly rather than only described. +MATRICES: dict[str, tuple[TaskSpec, ...]] = { + "v2": TASKS_V2, + "v3": TASKS_V3, + "v4": TASKS_V4, + "v5": TASKS_V5, +} + +DEFAULT_MATRIX = "v5" + + +def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec: + for task in MATRICES[matrix]: + if task.task_id == task_id: + return task + raise KeyError(f"{task_id} is not in matrix {matrix}") diff --git a/benchmarks/token_suite.py b/benchmarks/token_suite.py index 4f85724..48c2b97 100644 --- a/benchmarks/token_suite.py +++ b/benchmarks/token_suite.py @@ -488,6 +488,7 @@ async def idle_lane(counter: Any, turns: int = SESSION_TURNS) -> list[dict[str, """What a client pays on connect, for each advertised surface.""" srv = _load_server() previous_profile = config.settings.tool_profile + previous_packs = config.settings.tool_packs rows: list[dict[str, Any]] = [] try: config.settings.tool_profile = "full" @@ -510,6 +511,17 @@ async def idle_lane(counter: Any, turns: int = SESSION_TURNS) -> list[dict[str, ) ) config.settings.tool_profile = "full" + config.settings.tool_packs = "core,mech" + rows.append( + await _idle_row( + "packs_core_mech", + "TOOL_PACKS=core,mech - a mechanical client that never opens a " + "P&ID or a live seat.", + counter, + turns, + ) + ) + config.settings.tool_packs = previous_packs srv._apply_discovery_mode("search") rows.append( await _idle_row( @@ -523,6 +535,9 @@ async def idle_lane(counter: Any, turns: int = SESSION_TURNS) -> list[dict[str, finally: srv._apply_discovery_mode("off") config.settings.tool_profile = previous_profile + # restored here too, so a failed measurement cannot leave the process + # on a narrowed surface + config.settings.tool_packs = previous_packs await srv._apply_tool_profile(previous_profile) return rows diff --git a/docs/superpowers/specs/2026-09-22-v1.6-mech-design.md b/docs/superpowers/specs/2026-09-22-v1.6-mech-design.md index 8f05015..e03b99c 100644 --- a/docs/superpowers/specs/2026-09-22-v1.6-mech-design.md +++ b/docs/superpowers/specs/2026-09-22-v1.6-mech-design.md @@ -1,6 +1,6 @@ # v1.6 Tracks B + G — the complete mechanical 2D part drawer and the sheet standard -**Date:** 2026-09-22 · **Status:** design approved in conversation · +**Date:** 2026-09-22 · **Status:** shipped (v1.6.0-dev, tracks B + G) · **Parent:** `2026-09-15-v1.6-roadmap-design.md` (tracks B and G, merged into one spec by the user's decision) · **Baseline:** main after Track E (`39e6e2f`, 204 tools, 8 resources, 3283 tests) diff --git a/engineering/mech/dimension.py b/engineering/mech/dimension.py index 4e532b1..89e763a 100644 --- a/engineering/mech/dimension.py +++ b/engineering/mech/dimension.py @@ -423,6 +423,13 @@ def segment_key(index: int, *, bore: bool = False) -> str: def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]: + """Each segment's outside diameter and bore, measured across the axis. + + ``p1`` is the upper chord end: a diameter's text stands beyond ``p1`` (the + ActiveX ``AddDimDiametric`` ChordPoint rule, which both engines follow), so + every front-view diameter is annotated above the view, clear of the axial + chain rows below it. + """ intents: list[DimIntent] = [] bounds = zip(segment_bounds(part), part.segments, strict=True) for index, ((x0, x1), segment) in enumerate(bounds): diff --git a/engineering/mech/draw.py b/engineering/mech/draw.py index 0561412..f56e9e7 100644 --- a/engineering/mech/draw.py +++ b/engineering/mech/draw.py @@ -109,6 +109,7 @@ async def draw_prims( """Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``.""" placed = tuple(prims) _refuse_islands_without_edge_paths(backend, placed) + await _ensure_layers(backend, sorted({f"{layer_prefix}{layer_for(p)}" for p in placed})) if abs(float(rotation)) > 1e-12: placed = rotate(placed, float(rotation), (0.0, 0.0)) if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12: @@ -195,6 +196,23 @@ async def draw_prims( return {"handles": handles, "counts": counts} +async def _ensure_layers(backend, names) -> tuple[str, ...]: + """Create whichever target layers the drawing lacks, before the first entity. + + MEASURED on AutoCAD 2026 by ``scripts/smoke_mech_com.py``: ``draw_part`` + put its anchor POINT on ``MECH``, a layer no layer set creates, and + ActiveX answered ``entity.Layer = "MECH"`` with ``-0x7ffdfff7 ('Key not + found')`` - after every view had already been drawn. The headless engine + creates a missing layer on assignment, so the same call passed every test. + Group A measured the same refusal for annotation layers; this is the same + cure (``ensure_annotation_layers``) at the places every mechanical drawing + path passes through. + """ + from engineering.mech.annotate import ensure_annotation_layers + + return await ensure_annotation_layers(backend, [str(name) for name in names if name]) + + def _refuse_islands_without_edge_paths(backend, prims) -> None: """Refuse a hatch with islands on an engine that cannot attach them - before drawing. @@ -515,6 +533,7 @@ async def draw_part( if index == 0: parent, parent_offset = view, offset + await _ensure_layers(backend, [ANCHOR_LAYER]) anchor = await backend.entity_create_point(origin[0], origin[1], layer=ANCHOR_LAYER) payload = { "v": PAYLOAD_VERSION, @@ -839,6 +858,7 @@ async def dimension_part( skipped: list[dict] = [] table_handle: str | None = None + await _ensure_layers(backend, [ROLE_LAYER["dim"]]) for index, view, placed in planned: record = records[index] offset = record["offset"] @@ -963,6 +983,7 @@ async def draw_hole_pattern( prims.append(Line((hole.x - reach, hole.y), (hole.x + reach, hole.y), "center")) prims.append(Line((hole.x, hole.y - reach), (hole.x, hole.y + reach), "center")) drawn = await draw_prims(backend, prims) + await _ensure_layers(backend, [layer, ROLE_LAYER["dim"] if dimension else None]) if layer: # the role map owns the layers; an explicit override moves the circles # only, so the ISO 128-23 centre marks stay on CENTER. diff --git a/engineering/pid/graph.py b/engineering/pid/graph.py index 6e570a2..f6d720d 100644 --- a/engineering/pid/graph.py +++ b/engineering/pid/graph.py @@ -209,8 +209,19 @@ class _Grid: def _vertices(info: EntityInfo) -> list[tuple[float, float]]: + """The entity's vertices, or ``[]`` when the engine could not report them. + + MEASURED on AutoCAD 2026 by scripts/smoke_mech_com.py: the live engine's + entity reader drops a LINE's ``start``/``end`` when AutoCAD refuses the + point read (``RPC_E_CALL_REJECTED`` while busy) and keeps the entity, so + a subscript here crashed ``drawing_critique(focus=None)`` on a sheet with + no P&ID on it. A line without coordinates cannot be an edge; it is left + out rather than guessed at. + """ if info.type in LINE_TYPES: - s, e = info.properties["start"], info.properties["end"] + s, e = info.properties.get("start"), info.properties.get("end") + if not s or not e: + return [] return [(float(s[0]), float(s[1])), (float(e[0]), float(e[1]))] return [(float(p[0]), float(p[1])) for p in info.properties.get("points") or []] diff --git a/engineering/sheet/bom.py b/engineering/sheet/bom.py index 2bca393..7c99190 100644 --- a/engineering/sheet/bom.py +++ b/engineering/sheet/bom.py @@ -111,6 +111,11 @@ COLUMN_SHARES: dict[str, float] = { PAGE_SIZE = 1000 +#: A block reference under either engine's type name: the DXF name headlessly, +#: the ActiveX ObjectName (AcDbBlockReference) live. +INSERT_TYPES = ("INSERT", "BLOCKREFERENCE") + + async def all_entities(backend: AutoCADBackend, *, type_filter=None, layer=None): """Every entity of a type in the CURRENT space -- not the first 200 of them. @@ -361,7 +366,18 @@ async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int | N """ if limit is not None and int(limit) <= 0: raise ValueError(f"bom_extract: limit must be a positive number of records; got {limit!r}") - inserts = await all_entities(backend, type_filter="INSERT", layer=layer) + # MEASURED on AutoCAD 2026 by scripts/smoke_mech_com.py: the live engine + # names a block reference by its ObjectName, BLOCKREFERENCE, and filters on + # that name, so a filter on the DXF name INSERT found nothing and the parts + # list came back empty on a sheet holding an ISO 4014 bolt. Both names are + # read, the way engineering/pid/graph.py already does. + inserts: list = [] + seen: set[str] = set() + for type_name in INSERT_TYPES: + for entity in await all_entities(backend, type_filter=type_name, layer=layer): + if entity.handle not in seen: + seen.add(entity.handle) + inserts.append(entity) records: list[dict] = [] for entity in inserts: payload: dict = {} diff --git a/engineering/validator.py b/engineering/validator.py index 54cfe7a..cc3befb 100644 --- a/engineering/validator.py +++ b/engineering/validator.py @@ -27,6 +27,22 @@ DIMENSION_LIKE_TEXT = re.compile( re.IGNORECASE, ) +# A standard-part designation (ISO 4014 - M12x60, DIN 912 - M8x20) names a +# part, it does not measure one: the "12x60" in it is the thread and the +# nominal length the standard's designation grammar spells that way. A parts +# list drawn as text carries one per row, so the designation is taken out +# before DIMENSION_LIKE_TEXT looks - a hand-typed "Ø78" beside it still flags. +STANDARD_DESIGNATION = re.compile( + r"\b(?:ISO|DIN|EN)\s*\d+(?:-\d+)?\s*-\s*M\d+(?:\.\d+)?(?:\s*[x×]\s*\d+(?:\.\d+)?)?", + re.IGNORECASE, +) + + +def looks_like_dimension(text: str) -> bool: + """Is this TEXT/MTEXT content masquerading as a dimension?""" + return bool(DIMENSION_LIKE_TEXT.search(STANDARD_DESIGNATION.sub(" ", text or ""))) + + # R27 — match SPUR / HELICAL as whole words (not substrings of e.g. # "SPURIOUS") when checking title/helix consistency. _SPUR_RE = re.compile(r"\bSPUR\b", re.IGNORECASE) @@ -92,7 +108,7 @@ class DrawingValidator: e for e in ents if e.type in ("TEXT", "MTEXT") - and DIMENSION_LIKE_TEXT.search(getattr(e, "properties", {}).get("text", "") or "") + and looks_like_dimension(getattr(e, "properties", {}).get("text", "") or "") ] real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES] if text_likes: diff --git a/scripts/check_doc_numbers.py b/scripts/check_doc_numbers.py index 1088895..15c715a 100644 --- a/scripts/check_doc_numbers.py +++ b/scripts/check_doc_numbers.py @@ -79,7 +79,7 @@ def derived() -> dict[str, object]: } from benchmarks import correctness_suite - from benchmarks.tasks_v4 import TASKS_V4 + from benchmarks.tasks_v5 import TASKS_V5 return { "collected_tests": _collected_tests(), @@ -94,7 +94,7 @@ def derived() -> dict[str, object]: "ab_fixed": ab["fixed"], "ab_regressed": ab["regressed"], "suite_checks": len(correctness_suite.CHECKS), - "v4_tasks": len(TASKS_V4), + "matrix_tasks": len(TASKS_V5), "perf_ms": perf, } @@ -176,7 +176,7 @@ CHECKS: list[tuple[str, str, str, str]] = [ "ab_fixed", ), ("CLAUDE.md", "A/B suite size", r"correctness_suite\.py` \((\d+) checks\)", "ab_total"), - ("CLAUDE.md", "v4 task count", r"the current set \((\d+) tasks", "v4_tasks"), + ("CLAUDE.md", "matrix task count", r"the current set \((\d+) tasks", "matrix_tasks"), ] diff --git a/scripts/smoke_mech_com.py b/scripts/smoke_mech_com.py new file mode 100644 index 0000000..bca2c48 --- /dev/null +++ b/scripts/smoke_mech_com.py @@ -0,0 +1,290 @@ +"""Exercise tracks B and G once against the live AutoCAD on this machine. + +Creates NEW documents (never touches the operator's open drawings), then runs +the whole track through the same code the tools call: + + xref source: drawing_new -> a line -> drawing_export_dwg -> close + apply_layer_set("mech") -> apply_titleblock (ISO 5457 A3 + ISO 7200) + -> build_part -> draw_part -> add_view(section) -> dimension_part + -> insert_std_part (ISO 4014 block + ACADMCP_MECH xdata) + -> weld_symbol_prims -> extract_records -> rows_from_records + -> draw_bom_table -> add_balloon + -> xref_attach / xref_manage("list") / xref_manage("detach") + -> run_critique(focus=None) -> drawing_export_dwg + +and prints what came back as JSON. Exit codes: + + 0 the sheet drew, the critique returned nothing and the .dwg exists + 1 it drew but the critique found something or the DWG is missing + 2 no live CAD application reachable (pywin32 missing, or the ProgID + cannot be created) -- the COM paths then remain fake-tested only + +The backend's ``connect()`` is lazy on purpose (the server must start before +AutoCAD is open), so reachability is read from ``system_status()`` rather than +from ``connect()``. Every document this run opens is closed on the way out, +also when a step fails, unless ``--keep`` is given. + + AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_mech_com.py + uv run --frozen python scripts/smoke_mech_com.py --keep # leave it open +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +XDATA_APP = "ACADMCP_MECH" + +#: Every DWG since R13 opens with "AC10xx" (AC1032 for the 2018 format). +DWG_MAGIC = b"AC10" + +#: ``RPC_E_CALL_REJECTED``: AutoCAD's message filter refused the call because +#: it was busy (a document still initialising after ``Documents.Add`` is the +#: case measured on this seat). A refused call was not executed. +RPC_E_CALL_REJECTED = -2147418111 + +#: How long a refused call is retried before the smoke gives up on it. +REJECTED_PATIENCE_S = 90.0 + +#: Names of the documents this run opened and has not closed yet. +_OPENED: list[str] = [] + + +def _patient(backend) -> None: + """Retry any backend call AutoCAD refused with ``RPC_E_CALL_REJECTED``. + + Measured on AutoCAD 2026: the first call after ``Documents.Add`` was + refused, the smoke died, and its cleanup - refused as well - left the + scratch document open. Refused calls are retried with backoff for up to + ``REJECTED_PATIENCE_S``; a document created twice by a retried + ``drawing_new`` is still closed, because ``_new_document`` tracks the + document list, not a name it assumes. + """ + run = backend._run + + async def patient(func, *args, **kwargs): + deadline = time.monotonic() + REJECTED_PATIENCE_S + pause = 0.5 + while True: + try: + return await run(func, *args, **kwargs) + except RuntimeError as exc: + cause = exc.__cause__ + hr = cause.args[0] if cause is not None and cause.args else None + if hr != RPC_E_CALL_REJECTED or time.monotonic() + pause > deadline: + raise + await asyncio.sleep(pause) + pause = min(pause * 2.0, 5.0) + + backend._run = patient + + +async def _names(backend) -> set[str]: + return {row["name"] for row in await backend.document_list()} + + +async def _new_document(backend) -> str: + """``drawing_new``, recording every document it added (normally one).""" + before = await _names(backend) + await backend.drawing_new() + added = sorted((await _names(backend)) - before) + _OPENED.extend(name for name in added if name not in _OPENED) + return added[-1] if added else "" + + +async def _close_leftovers(backend) -> None: + open_names = {row["name"] for row in await backend.document_list()} + for name in list(_OPENED): + if name in open_names: + try: + await backend.document_close(name, discard=True) + except Exception as exc: # best effort on the way out + print(f"could not close {name}: {exc}", file=sys.stderr) + _OPENED.remove(name) + + +async def _xref_source(backend, workspace: Path) -> Path: + """A one-line DWG in its own scratch document, saved and closed. + + An xref has to be a DWG, and it cannot be the host document itself - + ``SaveAs`` rebinds the host to the file it wrote. + """ + created = await _new_document(backend) + await backend.entity_create_line(0.0, 0.0, 50.0, 0.0, layer="0") + path = workspace / "xref_source.dwg" + before = await _names(backend) + await backend.drawing_export_dwg(str(path), "R2018") + rebound = await _rebound(backend, created, before) + await backend.document_close(rebound, discard=True) + _OPENED.remove(rebound) + return path + + +async def _rebound(backend, created: str, before: set[str]) -> str: + """``SaveAs`` renames the active document to the file it wrote; track that name. + + Read from the document list rather than ``system_status``: the latter + swallows a refused call and answers without ``active_document``. + """ + after = await _names(backend) + renamed = sorted(after - before) + rebound = renamed[0] if created not in after and len(renamed) == 1 else created + if created in _OPENED: + _OPENED.remove(created) + if rebound not in _OPENED: + _OPENED.append(rebound) + return rebound + + +async def _sheet(backend, workspace: Path, xref_path: Path) -> dict: + from engineering.critique import run_critique + from engineering.layers import apply_layer_set + from engineering.mech.annotate import weld_symbol_prims + from engineering.mech.draw import add_view, dimension_part, draw_part, draw_prims + from engineering.mech.part import build_part + from engineering.mech.stdparts import insert_std_part + from engineering.sheet.bom import ( + add_balloon, + draw_bom_table, + extract_records, + rows_from_records, + ) + from engineering.sheet.titleblock import ( + TB_HEIGHT, + TitleBlockMetadata, + apply_titleblock, + titleblock_origin, + ) + + created = await _new_document(backend) + await apply_layer_set(backend, "mech") + await apply_titleblock( + backend, + size="A3", + metadata=TitleBlockMetadata(title="COM SMOKE", drawing_no="SMOKE-001", scale="1:1"), + projection="first", + frame=True, + zones=True, + marks=True, + ) + + shaft = build_part( + { + "kind": "revolved", + "name": "SHAFT", + "material": "steel", + "segments": [ + {"length": 40.0, "d_outer": 30.0}, + {"length": 20.0, "d_outer": 50.0, "d_inner": 20.0}, + ], + } + ) + drawn = await draw_part(backend, shaft, at=(125.0, 170.0), views=("front",), dimension=False) + section = await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [0.0, 0.0], "p2": [60.0, 0.0], "label": "A"}, + style="full", + ) + dims = await dimension_part(backend, drawn["part_id"], style="chain") + bolt = await insert_std_part(backend, "ISO 4014 - M12x60", at=(250.0, 120.0), view="side") + bolt_handle = bolt.get("handle") or (bolt.get("handles") or [None])[0] + got = await backend.entity_get_xdata(bolt_handle, XDATA_APP) + await draw_prims(backend, weld_symbol_prims(kind="fillet", size=5.0), at=(60.0, 100.0)) + + records = await extract_records(backend) + rows = rows_from_records(records) + tb_x, tb_y = titleblock_origin("A3") + table = await draw_bom_table(backend, rows, at=(tb_x, tb_y + TB_HEIGHT)) + balloon = await add_balloon( + backend, + item=int(rows[0]["item"]), + at=(250.0, 150.0), + leader_to=(250.0, 125.0), + targets=[str(h) for h in rows[0]["handles"]], + ) + + attached = await backend.xref_attach(str(xref_path), (30.0, 30.0), 1.0, 0.0, "attach") + listed = await backend.xref_manage(attached["name"], "list", None) + detached = await backend.xref_manage(attached["name"], "detach", None) + after = await backend.xref_manage(attached["name"], "list", None) + + issues = await run_critique(backend, None) + dwg_path = workspace / "mech_smoke.dwg" + before = await _names(backend) + exported = await backend.drawing_export_dwg(str(dwg_path), "R2018") + await _rebound(backend, created, before) + head = dwg_path.read_bytes()[:6] if dwg_path.exists() else b"" + return { + "part_id": drawn["part_id"], + "views": len(drawn.get("views") or []) + 1, + "section_omitted": section.get("omitted"), + "dimensions": int(dims.get("count") or len(dims.get("handles") or [])), + "bolt_handle": bolt_handle, + "xdata_chunks": len(got["xdata"].get(XDATA_APP, [])), + "parts_list_rows": len(rows), + "parts_list_representation": table.get("representation"), + "balloon": balloon.get("handle"), + "xref_attached": attached.get("name"), + "xref_listed": len(listed.get("xrefs") or []), + "xref_detached": bool(detached.get("ok")), + "xrefs_after_detach": len(after.get("xrefs") or []), + "dwg": { + "path": exported.get("path", str(dwg_path)), + "exists": dwg_path.exists(), + "bytes": dwg_path.stat().st_size if dwg_path.exists() else 0, + "magic": head.decode("ascii", "replace"), + }, + "critique_issues": len(issues), + "critique": [issue.to_dict() for issue in issues], + } + + +async def main(keep_document: bool = False) -> int: + try: + from backends.com_backend import ComBackend + except ImportError as exc: + print(f"pywin32 not available: {exc}") + return 2 + backend = ComBackend() + _patient(backend) + try: + await backend.connect() + except Exception as exc: + print(f"no live AutoCAD reachable: {exc}") + return 2 + try: + status = await backend.system_status() + if not status.get("connected"): + print(f"no live AutoCAD reachable: {status.get('error', status)}") + return 2 + workspace = Path(tempfile.mkdtemp(prefix="mech_smoke_")) + xref_path = await _xref_source(backend, workspace) + report = { + "autocad": status.get("autocad_version") or status.get("cad_progid"), + **await _sheet(backend, workspace, xref_path), + } + print(json.dumps(report, indent=2)) + ok = ( + report["critique_issues"] == 0 + and report["dwg"]["exists"] + and report["dwg"]["magic"].startswith(DWG_MAGIC.decode()) + and report["xrefs_after_detach"] == 0 + ) + return 0 if ok else 1 + finally: + if not keep_document: + await _close_leftovers(backend) + await backend.disconnect() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main(keep_document="--keep" in sys.argv[1:]))) diff --git a/tests/test_benchmark_v4.py b/tests/test_benchmark_v4.py index 25b6f75..3d84d12 100644 --- a/tests/test_benchmark_v4.py +++ b/tests/test_benchmark_v4.py @@ -48,7 +48,8 @@ def test_correctness_suite_has_the_settings_checks(): "settings_pdf_mediabox_a3", ): assert CHECKS[name][1] == "Settings" - assert len(CHECKS) == 32 + # v4 left the suite at 32; tracks B + G grew it to 39 (tests/test_benchmark_v5.py). + assert len(CHECKS) == 39 @pytest.mark.asyncio # asyncio_mode is strict; the four tests above are sync diff --git a/tests/test_benchmark_v5.py b/tests/test_benchmark_v5.py new file mode 100644 index 0000000..446753c --- /dev/null +++ b/tests/test_benchmark_v5.py @@ -0,0 +1,112 @@ +# tests/test_benchmark_v5.py +"""The v5 task matrix: v4 plus what tracks B and G added. + +``mech_assembly`` is roadmap criterion 3 — a flange-coupling sheet built from +the tools, gated on two numbers that cannot be negotiated: +``drawing_critique(focus=None)`` returns zero issues and ``drawing_finalize`` +scores at least 90. The published competitor reports were never asked it and +the chart shows them ``not_run``, the rule v3 set for its five. +""" + +from __future__ import annotations + +import pytest + +from benchmarks.tasks_v4 import TASKS_V4 +from benchmarks.tasks_v5 import DEFAULT_MATRIX, MATRICES, NEW_TASKS_V5, TASKS_V5, task_by_id + + +def test_v5_extends_v4_without_touching_it(): + assert TASKS_V5[: len(TASKS_V4)] == TASKS_V4 + assert [t.task_id for t in NEW_TASKS_V5] == ["mech_assembly"] + assert len(TASKS_V5) == 18 + assert MATRICES["v4"] == TASKS_V4 and MATRICES["v5"] == TASKS_V5 + assert DEFAULT_MATRIX == "v5" + assert task_by_id("mech_assembly").category == "mech" + + +def test_the_adapter_implements_every_v5_task(): + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + + for task in TASKS_V5: + assert hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}"), task.task_id + + +def test_the_suite_carries_the_mechanical_and_sheet_checks(): + """Six from spec 14, plus mech_thread_unrepresented_is_caught: a quality + gate that never fires is not a gate, the way dim_overlap_critique_fires + already guards the dimension focus.""" + from benchmarks.correctness_suite import CHECKS + + for name in ( + "mech_part_roundtrip", + "mech_section_hatch_area", + "mech_iso286_on_dimension", + "mech_thread_unrepresented_is_caught", + "std_part_iso4014_m12", + ): + assert CHECKS[name][1] == "Mechanical" + assert CHECKS["sheet_frame_iso5457_a3"][1] == "Sheet" + assert CHECKS["bom_balloon_link"][1] == "Sheet" + assert len(CHECKS) == 39 + + +@pytest.mark.asyncio +async def test_mech_assembly_passes_headlessly(tmp_path): + """The benchmark task itself, run once here so a regression in the part + drawer, the sheet or the critique fails the suite and not only the matrix.""" + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + + adapter = AutoCADMCPProAdapter(backend="ezdxf") + await adapter.setup(tmp_path) + try: + await adapter._reset() + passed, metrics, _artifacts = await adapter._task_mech_assembly() + finally: + await adapter.cleanup() + assert passed, metrics + assert metrics["critique_issues"] == 0, metrics["critique_focuses"] + assert metrics["score"] >= 90.0 + + +@pytest.mark.asyncio +async def test_mech_assembly_fails_loudly_when_the_critique_finds_anything(tmp_path, monkeypatch): + """The gate has to be able to fail. One parts-list row left without its + balloon is enough: ISO 7573 gives every row an item reference, and the + critique names the focus that fired rather than only shading the score.""" + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + from engineering.sheet import bom + + real = bom.add_balloon + + async def only_the_first(backend, *, item, **kw): + if int(item) == 1: + return await real(backend, item=item, **kw) + return {"ok": True, "skipped": True} + + monkeypatch.setattr(bom, "add_balloon", only_the_first) + adapter = AutoCADMCPProAdapter(backend="ezdxf") + await adapter.setup(tmp_path) + try: + await adapter._reset() + passed, metrics, _artifacts = await adapter._task_mech_assembly() + finally: + await adapter.cleanup() + assert passed is False + assert "mech_bom_balloon_mismatch" in metrics["critique_focuses"] + + +@pytest.mark.asyncio +async def test_the_section_hatch_area_check_measures_the_drawing(): + """1200 mm^2 is computed by hand from the model, and read back through + analysis_measure_entity — a number the drawing code cannot fake.""" + from benchmarks import correctness_suite + + assert await correctness_suite.mech_section_hatch_area() is True + + +@pytest.mark.asyncio +async def test_the_iso5457_frame_check_passes(): + from benchmarks import correctness_suite + + assert await correctness_suite.sheet_frame_iso5457_a3() is True diff --git a/tests/test_com_backend.py b/tests/test_com_backend.py index 07cab5f..75fe2ba 100644 --- a/tests/test_com_backend.py +++ b/tests/test_com_backend.py @@ -546,6 +546,27 @@ def _gate_valve_block(): return _Block([body_left, body_right, tag]) +def test_a_dimension_reports_where_its_text_stands(): + """The dim_overlap critique reads `text_position`; without it a diameter + dimension's reference point was its bounding-box centre - the circle's + centre - and every outside diameter collided with its own bore on the live + engine (measured by scripts/smoke_mech_com.py, AutoCAD 2026).""" + from backends.com_backend import _entity_info + + outside = _Entity( + "AcDbDiametricDimension", + GetBoundingBox=lambda: ((178.0, 170.0, 0.0), (184.0, 233.0, 0.0)), + TextPosition=(181.0, 230.0, 0.0), + Handle="1C1", + Layer="DIM", + color=256, + Linetype="ByLayer", + Visible=True, + ) + props = _entity_info(outside).properties + assert props["text_position"] == [181.0, 230.0] + + def test_com_geometry_bbox_is_the_drawn_body_without_the_attribute(): from backends.com_backend import _entity_info diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 1af5942..2696a53 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -57,6 +57,17 @@ async def test_a_diameter_dimension_measures_the_diameter(backend): assert dim.get_measurement() == pytest.approx(40.0) +async def test_the_text_stands_beyond_the_first_chord_point_as_activex_puts_it(backend): + """AddDimDiametric(ChordPoint, FarChordPoint, LeaderLength), measured on + AutoCAD 2026: a 50 mm diameter from (x, 170) to (x, 220) with leader 10 + had its text at (x, 160) - beyond the FIRST point. Both engines follow it.""" + info = await backend.dimension_diameter(0, 25, 0, -25, leader_length=10.0) + dim = backend._doc.entitydb.get(info.handle) + text = dim.dxf.text_midpoint + assert text.y == pytest.approx(35.0) + assert text.x == pytest.approx(0.0) + + async def test_the_leader_length_moves_the_text_not_the_measurement(backend): """The regression in one assertion: leader_length must not be measurable.""" near = await backend.dimension_diameter(-20, 0, 20, 0, leader_length=5.0) diff --git a/tests/test_engineering_validator.py b/tests/test_engineering_validator.py index 0fd1f82..5e2b43d 100644 --- a/tests/test_engineering_validator.py +++ b/tests/test_engineering_validator.py @@ -101,6 +101,21 @@ async def test_validator_catches_fake_dimension_text(backend, tmp_path): assert "fake_dimension_text" in _codes(result) +async def test_a_standard_part_designation_is_not_a_fake_dimension(): + """A parts-list row names a part: "ISO 4014 - M12x60" is the ISO 4014 + designation grammar, not a measurement. Found by the v5 mech_assembly + benchmark, where every text parts list cost the sheet a warning. The + exemption is the designation only - a measurement beside it still flags.""" + from engineering.validator import looks_like_dimension + + assert not looks_like_dimension("ISO 4014 - M12x60") + assert not looks_like_dimension("DIN 912 - M8x20") + assert not looks_like_dimension("ISO 4032 - M12") + assert looks_like_dimension("ISO 4014 - M12x60 Ø78") + assert looks_like_dimension("40x60") + assert looks_like_dimension("Ø78") + + async def test_validator_catches_hidden_orphans(backend, tmp_path): await ensure_standard_linetypes(backend) await ensure_engineering_layers(backend) diff --git a/tests/test_mech_draw.py b/tests/test_mech_draw.py index 800290b..ba4c020 100644 --- a/tests/test_mech_draw.py +++ b/tests/test_mech_draw.py @@ -492,3 +492,65 @@ async def test_a_transaction_that_cannot_open_is_a_value_error(backend, monkeypa monkeypatch.setattr(backend, "transaction_begin", busy) with pytest.raises(ValueError, match="could not open a transaction"): await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": [0.0, 0.0]}]}) + + +async def test_a_single_step_disk_dimensions_without_a_text_collision(backend): + """The largest diameter's text used to land on its own segment's chain text. + + The front-view diameters start at the upper chord end, and ActiveX puts a + diameter's text beyond that FIRST point - above the view, clear of the + chain rows (measured on AutoCAD 2026). The headless engine put it beyond + the second point instead, so ⌀90 landed exactly on the "15" of a 15 mm + disk and ``dim_overlap`` fired on every single-step part the default + ``mech_part_draw`` produced headlessly. Found by the v5 ``mech_assembly`` + benchmark and the live smoke together. + """ + from engineering.critique import run_critique + + disk = build_part( + { + "kind": "revolved", + "name": "FLANGE", + "material": "cast_iron", + "segments": [{"length": 15.0, "d_outer": 90.0, "d_inner": 40.0}], + } + ) + await draw_part(backend, disk, at=(0.0, 0.0), views=("front",), dimension=True) + assert await run_critique(backend, ["dim_overlap"]) == [] + + +async def test_every_layer_exists_before_an_entity_is_put_on_it(backend, monkeypatch): + """ActiveX will not create a layer on assignment - the headless engine will. + + Measured on AutoCAD 2026 by scripts/smoke_mech_com.py: ``entity.Layer = + "MECH"`` raised ``-0x7ffdfff7 ('Key not found')`` after every view of the + part had been drawn, so ``mech_part_draw`` could not finish on a live + seat. This backend is made as strict as ActiveX, and the whole part - + views, anchor, dimensions - has to draw on it. + """ + creators = ( + "entity_create_point", + "entity_create_line", + "entity_create_circle", + "entity_create_arc", + "entity_create_polyline", + "entity_create_text", + "entity_create_hatch", + ) + for name in creators: + real = getattr(backend, name) + + async def strict(*args, _real=real, **kwargs): + layer = kwargs.get("layer") + known = {row.name.lower() for row in await backend.layer_list()} + if layer and layer.lower() not in known: + raise RuntimeError(f"Key not found: layer {layer!r}") + return await _real(*args, **kwargs) + + monkeypatch.setattr(backend, name, strict) + + drawn = await draw_part( + backend, build_part(SHAFT), at=(0.0, 0.0), views=("front", "side"), dimension=True + ) + assert drawn["part_id"] + assert ANCHOR_LAYER.lower() in {row.name.lower() for row in await backend.layer_list()} diff --git a/tests/test_pid_graph.py b/tests/test_pid_graph.py index 57e7e38..7b99081 100644 --- a/tests/test_pid_graph.py +++ b/tests/test_pid_graph.py @@ -896,3 +896,14 @@ def test_com_block_reference_reports_mirrored_from_its_normal(): assert _entity_info(blockref(Normal=(0.0, 0.0, -1.0))).properties["mirrored"] is True assert _entity_info(blockref(Normal=(0.0, 0.0, 1.0))).properties["mirrored"] is False assert _entity_info(blockref()).properties["mirrored"] is False + + +def test_a_line_the_engine_could_not_read_is_not_an_edge(): + """The live reader keeps a LINE whose point read AutoCAD refused, without + ``start``/``end`` (measured by scripts/smoke_mech_com.py on AutoCAD 2026); + the graph must leave it out instead of raising KeyError mid-critique.""" + from backends.base import EntityInfo + from engineering.pid.graph import _vertices + + blind = EntityInfo("1F", "LINE", "GEOMETRY", 256, "ByLayer", True, properties={}) + assert _vertices(blind) == [] diff --git a/tests/test_sheet_bom.py b/tests/test_sheet_bom.py index bf95ada..4616493 100644 --- a/tests/test_sheet_bom.py +++ b/tests/test_sheet_bom.py @@ -224,6 +224,30 @@ async def test_extract_reads_the_xdata_payload_and_never_touches_the_drawing(bac assert all(r["source"] == "xdata" for r in records) +@pytest.mark.asyncio +async def test_extract_reads_a_block_reference_under_the_live_type_name(backend, monkeypatch): + """The live engine calls an INSERT by its ObjectName, BLOCKREFERENCE. + + Measured on AutoCAD 2026 by scripts/smoke_mech_com.py: a filter on + "INSERT" found nothing, and the parts list of a sheet holding an ISO 4014 + bolt came back empty. The listing is made to answer the way ActiveX does. + """ + handle = await _place_bolt(backend, "a", 0.0) + real = backend.entity_list + + async def live_names(type_filter=None, **kwargs): + if type_filter and type_filter.upper() == "INSERT": + return [] + wanted = ( + "INSERT" if type_filter and type_filter.upper() == "BLOCKREFERENCE" else type_filter + ) + return await real(type_filter=wanted, **kwargs) + + monkeypatch.setattr(backend, "entity_list", live_names) + records = await extract_records(backend) + assert [r["handle"] for r in records] == [handle] + + @pytest.mark.asyncio async def test_extract_falls_back_to_the_block_attributes_when_there_is_no_payload(backend): await backend.block_define( diff --git a/tests/test_token_suite.py b/tests/test_token_suite.py index c05c315..7455bf6 100644 --- a/tests/test_token_suite.py +++ b/tests/test_token_suite.py @@ -161,13 +161,25 @@ def test_the_cache_arithmetic_follows_the_published_multipliers(): # -- lane 1: idle cost ------------------------------------------------------ -def test_the_idle_lane_covers_the_three_advertised_surfaces(report): +def test_the_idle_lane_covers_the_four_advertised_surfaces(report): + """Default, lean, search - and TOOL_PACKS=core,mech, the mechanical client + that never opens a P&ID or a live seat: the default surface minus exactly + the pid and settings packs.""" variants = {row["variant"]: row for row in report["idle"]} - assert set(variants) == {"default", "lean", "search"} + assert set(variants) == {"default", "lean", "packs_core_mech", "search"} assert variants["default"]["advertised_tools"] > 100 assert variants["lean"]["advertised_tools"] == len(server.LEAN_TOOL_NAMES) assert variants["search"]["advertised_tools"] == 2 - assert variants["default"]["chars"] > variants["lean"]["chars"] > variants["search"]["chars"] + dropped = len(server.PACK_TOOL_NAMES["pid"]) + len(server.PACK_TOOL_NAMES["settings"]) + assert variants["packs_core_mech"]["advertised_tools"] == ( + variants["default"]["advertised_tools"] - dropped + ) + assert ( + variants["default"]["chars"] + > variants["packs_core_mech"]["chars"] + > variants["lean"]["chars"] + > variants["search"]["chars"] + ) def test_the_idle_lane_separates_the_wire_payload_from_what_the_model_pays(report):