Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fd6d2310 | ||
|
|
cdb1063896 | ||
|
|
17bf3a39ce | ||
|
|
7e7fe11a71 | ||
|
|
554daff8d0 | ||
|
|
db5b4d3ef8 | ||
|
|
77cb325467 | ||
|
|
ac32700ba8 | ||
|
|
307ccc799f | ||
|
|
ba41428c88 |
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.0] — 2026-09-25
|
||||
|
||||
The v1.6 roadmap, shipped as five tracks: 154 → 247 registered tools in 28
|
||||
groups, the correctness suite at 48 / 48 against v1.5.1's 26 / 48 with zero
|
||||
regressions, the 21-task benchmark matrix at 21 / 21, and a live AutoCAD 2026
|
||||
run of every track's COM paths. Two roadmap criteria are not green, and
|
||||
**Not in 1.6** below says which and why: track D (Plant 3D) was dropped, and
|
||||
headless creation throughput is still below v1.4.0's. Roadmap:
|
||||
`docs/superpowers/specs/2026-09-15-v1.6-roadmap-design.md`.
|
||||
|
||||
Track A of 1.6: the server becomes a P&ID expert and a P&ID reader. Nine
|
||||
`pid_*` tools in a new SECTION 17, two generic contracts every engine
|
||||
implements, six critique focuses that join `drawing_finalize`, and a
|
||||
@@ -39,6 +49,16 @@ new `arch` pack, three `arch_*` critique focuses, and English or Turkish
|
||||
labels. No new contract member. Spec:
|
||||
`docs/superpowers/specs/2026-09-23-v1.6-arch-design.md`.
|
||||
|
||||
Track H of 1.6: understanding drawings somebody else made. Six tools in two
|
||||
new sections — understanding and QA (26: a one-call report, a scale check
|
||||
between a P&ID and its layout, a revision diff, topology checks) and plant
|
||||
takeoffs (27: pipe and cable quantities whose topology comes from the P&ID
|
||||
and whose lengths come from the layout) — one DXF snapshot per drawing (a
|
||||
live document through `Document.Export`, never renamed), a vocabulary in five
|
||||
languages, three `topo_*` critique focuses, a new `plant` pack and the
|
||||
`office` extra. Read-only by default; nothing invented. No new contract
|
||||
member. Spec: `docs/superpowers/specs/2026-09-24-v1.6-understand-design.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Styles — 10 tools, pack `core`.** `dimstyle_list/create/modify/set_current`,
|
||||
@@ -139,6 +159,33 @@ labels. No new contract member. Spec:
|
||||
2026 (`25.1s (LMS Tech)`): the example plan drawn in one transaction, both
|
||||
rooms read back within 0.1 % of their labels and of the hand-computed net
|
||||
floor, and `drawing_critique(focus=None)` returned 0 issues (record below).
|
||||
- **Understanding foreign drawings — 4 tools, pack `core`; plant takeoffs — 2
|
||||
tools, pack `plant`.** `drawing_understand`, `drawing_scale_check`,
|
||||
`drawing_diff`, `drawing_topology_check`, `pipe_takeoff`, `cable_takeoff`.
|
||||
Every one reads a snapshot of frozen WCS records
|
||||
(`engineering/understand/snapshot.py`); labels are read by one parser
|
||||
(`%%c`, `\U+2205`, the U+2300 sign, `ø` and MTEXT format runs decoded,
|
||||
Cyrillic look-alike tags folded to Latin) and layer names and texts by one
|
||||
EN / TR / RU / NL / DE vocabulary. The takeoff workbook is written as CSV
|
||||
always and as XLSX with the new `office` extra (`openpyxl`, also in `full`).
|
||||
Lean gains `drawing_understand` and reaches 65.
|
||||
- **Benchmark evidence for track H.** `benchmarks/tasks_v7.py` (21 tasks, the
|
||||
default matrix; v6 to v2 stay addressable) adds `takeoff_roundtrip` and
|
||||
`understand_foreign`, both on the synthetic plant pair of
|
||||
`tests/fixtures/plant_pair.py`, whose truth is computed from the positions
|
||||
it placed; each gate is shown to fail on a broken input. The correctness
|
||||
suite grows to 48 with `scale_check_detects_schematic`,
|
||||
`pipe_takeoff_rmst_exact`, `cable_takeoff_roundup`,
|
||||
`diff_detects_known_edits` and `topology_known_defects`. A/B against
|
||||
`v1.5.1`: **26 / 48 → 48 / 48, twenty-two `miss → pass`, zero regressed**.
|
||||
- **`scripts/field_test_takeoff.py`** runs both takeoffs on a user's own P&ID
|
||||
and layout and writes the report next to them; it refuses an output folder
|
||||
inside the repository and prints no quantity unless `--show` is given.
|
||||
- **Live COM smoke.** `scripts/smoke_understand_com.py` executed once on
|
||||
AutoCAD 2026 (`25.1s (LMS Tech)`): the live snapshots matched
|
||||
their files type for type in model space, the DWG route read the same
|
||||
P&ID, both takeoffs matched the truth and the diff reported exactly the
|
||||
one entity moved (record below).
|
||||
|
||||
- **P&ID — 9 tools, pack `pid`.**
|
||||
- `pid_symbol_list` / `pid_symbol_insert`: a 43-symbol ISO 10628-2 /
|
||||
@@ -218,6 +265,49 @@ labels. No new contract member. Spec:
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every tool call raised `RecursionError` after a few hundred in-process
|
||||
sessions.** fastmcp 3's `enable()` / `disable()` append a Visibility
|
||||
transform per call, and a tool lookup walks the whole transform stack as
|
||||
nested calls. `_apply_tool_profile` runs in the lifespan, once per client
|
||||
connection, so each session stacked two more layers. The first Linux CI run
|
||||
of the 1.6 tree found it: on CPython 3.11, which counts C frames against the
|
||||
recursion limit, `system_status` itself failed from about 90 % of the suite
|
||||
on. The profile's transforms now replace the previous ones instead of
|
||||
stacking on them, the idiom `_apply_discovery_mode` already used, and a test
|
||||
switches the profile 600 times and then calls a tool.
|
||||
- The same CI run failed 21 fake-ActiveX tests off Windows (they build a real
|
||||
pywin32 VARIANT and now skip without pywin32, like the suite's other
|
||||
VARIANT-dependent COM tests; the Windows lane runs them), and a test that
|
||||
spelled a path through a directory it never created, which only Windows
|
||||
normalises away.
|
||||
- **Found by the 1.6.0 README renders** (each with a test that fails on the
|
||||
old code):
|
||||
- The exterior dimension chains of `arch_dimension_chains` and
|
||||
`arch_plan_from_spec` were drawn at the dimension style's DIMSCALE 1, so a
|
||||
1:50 chain's 2.5 text was 2.5 drawing units high — 0.05 mm on the sheet —
|
||||
and the plan render showed empty boxes where the numbers belong. Every
|
||||
other plan annotation already followed the plot scale, and now the chains
|
||||
do too: DIMSCALE is the plan's scale while they are drawn (reported as
|
||||
`dimscale`, replacing the old advice to set it by hand), and the drawing's
|
||||
own DIMSCALE is put back afterwards. Headlessly each chain dimension
|
||||
carries the scale as its override. The live engine reaches the same
|
||||
DIMSCALE through `SetVariable`; that combination has not been re-run on a
|
||||
live seat since this change.
|
||||
- A headless TABLE drew its title as the first column's cell, so a title
|
||||
such as `DOOR SCHEDULE` wrapped inside that column and ran over the header
|
||||
row. The title row is now merged across the table, as AutoCAD's TABLE
|
||||
draws it.
|
||||
- A headless TABLE's cell margin was a flat 1.0 drawing unit — 0.02 mm on a
|
||||
1:50 schedule, where the text sat on the cell line and `LEFT` read as
|
||||
`FT`. The margin now scales with the lettering (0.4 × text height, at most
|
||||
0.15 × row height), which leaves every table at the default 2.5 text as
|
||||
it was.
|
||||
- `drawing_new` left the Standard text style on ezdxf's default `txt`,
|
||||
which has no superscript two: a room label's `m²` read `m?` when AutoCAD
|
||||
opened the DXF and rendered as a box headlessly. Standard is now
|
||||
`arial.ttf`, as AutoCAD 2026's `acadiso.dwt` ships it, so both engines
|
||||
begin from the same Standard; the five bundled templates are rebuilt on it
|
||||
and `scripts/build_templates.py --check` reproduces them.
|
||||
- **Hatch islands on a live seat.** `hatch_add_boundary` was refused on COM
|
||||
(`hatch_edge_paths`), so a transverse section of any hollow part - a
|
||||
bushing, a flange bore - could not be drawn in AutoCAD. Each typed edge now
|
||||
@@ -318,8 +408,94 @@ labels. No new contract member. Spec:
|
||||
`KeyError: 'start'` recorded above as a busy-refused point read (not
|
||||
re-measured).
|
||||
|
||||
- **Track H, found after the groups landed.** Six defects in the readers,
|
||||
each with a test that failed before its fix:
|
||||
- A truncated DXF (an interrupted save, a partial copy) made ezdxf raise
|
||||
`StopIteration`, which `asyncio.to_thread` cannot hand back to an
|
||||
awaiting caller, so every track H tool reading one would have hung. The
|
||||
snapshot now refuses it as "not a readable DXF file".
|
||||
- `take_snapshot` of a DWG the operator already had open opened it again
|
||||
read-only and closed "it" unsaved - possibly their copy, with their
|
||||
unsaved work. An open document is now looked up first, exported as it
|
||||
stands and left open; only a document the snapshot opened is closed.
|
||||
- `drawing_diff`: a LINE drawn back to front and moved was reported as
|
||||
reshaped; an INSERT carrying one attribute tag twice could lose an edit
|
||||
of the first value (a repeated tag is now compared as the list of its
|
||||
values); the cluster list is capped like the other lists
|
||||
(`truncated["clusters"]`, the markup still clouds every cluster); and a
|
||||
whole-drawing move keeps only the 32 nearest candidates per entity.
|
||||
- A line number was read as an equipment tag: `100-P-001` yielded the pump
|
||||
`P-001`. A token that is the tail of a word beginning with a digit is no
|
||||
longer a tag.
|
||||
- The pipe network merged ends and dropped stubs within `tol = 1.0`
|
||||
drawing units - a metre on a metre P&ID. The tolerance now defaults to
|
||||
1 mm in the P&ID's inferred unit, and a dropped stub is counted and said.
|
||||
- A wiring callout was counted as a place its panel is written:
|
||||
`Wiring to CP-2` made `CP-2` ambiguous in `drawing_scale_check` and
|
||||
`drawing_understand`, and `Wiring to CP1` a phantom tag `CP1`. On the
|
||||
synthetic pair the check matched 10 tags and 45 pairs (13.3 % within
|
||||
±10 %) where the generator placed 11 and 55 (21.8 %). A callout is no
|
||||
longer a tag occurrence - the rule the line-network reader already kept.
|
||||
- **Track H, found by the field test** (the takeoffs run on a real P&ID pair
|
||||
that stays outside the repository). Each fix has a test that failed first:
|
||||
- The pipe network took every piping or electrical layer, so valve, pump
|
||||
and annotation layers (sizes, materials, numbers) became pipe runs.
|
||||
`vocab.is_network_layer` is now the one rule for the network, the
|
||||
topology check and the `topo_*` focuses: piping or electrical at 0.9, and
|
||||
naming neither equipment nor annotation.
|
||||
- Measured on the P&ID, a run still needed equipment tags at both ends, so
|
||||
a field P&ID whose pipes rarely end on a tagged outline gave an empty
|
||||
table. On the P&ID each piece now goes to the room it lies in, with its
|
||||
diameter kept piece by piece. A layout takeoff that cannot route every run
|
||||
now says how many, why, and what the P&ID alternative is.
|
||||
- TEXT caret notation (`^J`, a line break) was left in place, so a room
|
||||
label's number read as `6^J...`. `plain()` now decodes it.
|
||||
- A label that only mentions a room was read as a room: a control cabinet
|
||||
naming the room it serves, or a note pointing into a room. An unnumbered
|
||||
label naming equipment is no room. Without room faces, when the drawing
|
||||
numbers its rooms, only numbered labels take pieces.
|
||||
- The bare Russian "to" before a panel (`к CP1`, `к щиту CP-2`) is a
|
||||
wiring callout.
|
||||
- **Track H, what the field test asked for** (each with a synthetic test):
|
||||
- **Rooms drawn as frames.** A room label no wall face holds takes the
|
||||
smallest closed, straight-sided polyline around it that holds no other
|
||||
room number and is at least ten label heights a side (`label+frame`,
|
||||
confidence 0.8). This is how a P&ID draws its rooms, so P&ID lengths are
|
||||
cut at the room frames.
|
||||
- **The nearest panel, flagged.** `cable_takeoff(panel_rule="nearest")`,
|
||||
the default: a load that no callout places, whose tag is on the layout,
|
||||
takes the nearest panel there. Only names with a panel prefix
|
||||
(`is_panel_name`: CP, JB, DB, MCC ...) or names a callout gives are
|
||||
candidates. The row is flagged `panel_inferred`, and an inferred panel
|
||||
never makes a package. `panel_rule="stated"` leaves such a load open. The
|
||||
benchmark and the correctness check read stated panels only.
|
||||
- **Possible packages and detail copies.** `pipe_takeoff` reports
|
||||
`detail_copies`: compact groups of tags drawn twice (`repeated`), and
|
||||
compact groups of one equipment's own parts, such as M150B, M150C and
|
||||
M150D of M150 (`family`). A pasteurizer drawn in detail, whose internal
|
||||
pipes its vendor supplies, shows up this way. Nothing is removed until
|
||||
`exclude_regions=["D2"]` names it. The excluded runs go to the control
|
||||
rows as `excluded_region` and are totalled in `excluded_m`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release evidence re-measured for 1.6.0.** The A/B gate against v1.5.1
|
||||
(`ab-v1.5.1-vs-v1.6.0.json`: 26 / 48 → 48 / 48, 22 fixed, 0 regressed); the
|
||||
reference adapter on matrix v7 (`autocad-mcp-pro.json`: 21 / 21, replacing
|
||||
the v3 run of 1.5); the 100-point rubric re-scored per category with the
|
||||
evidence for every point withheld (93; the competitor rows are their July
|
||||
snapshots, not re-reviewed); the perf lane as medians of five alternating
|
||||
runs, with the v1.4.0 comparison published beside it
|
||||
(`perf-v1.4.0-vs-v1.6.0.json`). The four charts are regenerated from those
|
||||
files, and the README is rewritten around them, with the mechanical and
|
||||
architectural sheets rendered by `scripts/render_readme_mech.py` and
|
||||
`scripts/render_readme_arch.py`.
|
||||
- `ruff` skips `docs/superpowers`: ruff 0.16 formats Markdown code blocks,
|
||||
and reflowing a plan's quoted code would change what the document says was
|
||||
written.
|
||||
- **`MAX_DXF_BYTES` defaults to 512 MB** (was 50 MB): a real plant layout read
|
||||
for track H was a 188 MB DXF. The snapshot's refusal names the file's size,
|
||||
the limit and the variable; `0` still disables the check.
|
||||
- Three tool groups appear in `system_about` and `docs/tool-inventory.json`:
|
||||
`styles` (10), `page_setup` (6), `environment` (22) — 204 tools in 23 groups;
|
||||
`LEAN_TOOL_NAMES` 55; capability keys `dwgprops`, `dwt_write`,
|
||||
@@ -349,6 +525,27 @@ labels. No new contract member. Spec:
|
||||
the README and Linux collection predates both tracks and is superseded by
|
||||
the new figure.
|
||||
|
||||
### Not in 1.6
|
||||
|
||||
- **Track D — Plant 3D, read-only — is dropped**, and roadmap criterion 5 is
|
||||
withdrawn, not met. The maintainer does not use Plant 3D, and a reader
|
||||
tested against nobody's real projects would be a guess. `TOOL_PACKS` keeps
|
||||
no `plant3d` pack; the `plant` pack is track H's takeoffs.
|
||||
- **Roadmap criterion 7 is not met: headless creation is slower than
|
||||
v1.4.0.** Medians of five alternating runs, one CPython 3.11.15 and one
|
||||
ezdxf over each tree's own code: 2,000 line creates 244.5 → 306.1 ms
|
||||
(1.25×), the 10,000-line build / export / reopen 1,847.9 → 2,553.5 ms
|
||||
(1.38×); with `EZDXF_CALL_TIMEOUT=0` still 1.17× and 1.20×. The premium
|
||||
quality pass went the other way, 150.6 → 65.3 ms. A cProfile of the
|
||||
event-loop thread over the 2,000 creates counts 491,071 function calls on
|
||||
1.6.0 against 319,038 on v1.4.0. The timeout is part of the cost, not all
|
||||
of it, and earlier notes that switching it off restored v1.4.0's numbers
|
||||
were wrong. Carried to 1.7.
|
||||
- **Four mechanical feature tables ship without rows**: DIN 471/472, DIN 509,
|
||||
DIN 332 and ISO 3601-2. Their drawing code and provenance tests are in;
|
||||
every size is refused by name until rows verified against the standard's
|
||||
own table are transcribed.
|
||||
|
||||
### Live COM smoke
|
||||
|
||||
`scripts/smoke_pid_com.py`, AutoCAD 2026 (`25.1s (LMS Tech)`), Windows 11,
|
||||
@@ -949,6 +1146,86 @@ The first run exited 1: the first room label was refused because the room
|
||||
reader found 0 wall segments on `A-WALL-E-N` — the `entity_list` narrowing
|
||||
listed under **Fixed**. The run above is the one after the fix.
|
||||
|
||||
### Live COM smoke — understanding (track H)
|
||||
|
||||
`scripts/smoke_understand_com.py`, AutoCAD 2026, Windows 11, on the synthetic
|
||||
plant pair only — `take_snapshot` of `pid.dxf` opened live, the same P&ID
|
||||
saved as DWG and read back through the read-only DWG route, `take_snapshot`
|
||||
of `layout.dxf`, `pipe_rows` / `cable_rows` on the live snapshots against the
|
||||
pair's truth, then one tag moved 10 mm and `diff_snapshots` of the layout
|
||||
before and after, all through the COM engine, exit 0, every document closed:
|
||||
|
||||
```json
|
||||
{
|
||||
"autocad": "25.1s (LMS Tech)",
|
||||
"export_s": 0.104,
|
||||
"live_pid_source": "live:pid.dxf",
|
||||
"dwg_pid_source": "live:pid.dwg",
|
||||
"model_space": {
|
||||
"pid_file": {
|
||||
"ARC": 1,
|
||||
"INSERT": 15,
|
||||
"LINE": 8,
|
||||
"LWPOLYLINE": 8,
|
||||
"MTEXT": 8,
|
||||
"TEXT": 31
|
||||
},
|
||||
"pid_live": {
|
||||
"ARC": 1,
|
||||
"INSERT": 15,
|
||||
"LINE": 8,
|
||||
"LWPOLYLINE": 8,
|
||||
"MTEXT": 8,
|
||||
"TEXT": 31
|
||||
},
|
||||
"pid_dwg": {
|
||||
"ARC": 1,
|
||||
"INSERT": 15,
|
||||
"LINE": 8,
|
||||
"LWPOLYLINE": 8,
|
||||
"MTEXT": 8,
|
||||
"TEXT": 31
|
||||
},
|
||||
"layout_file": {
|
||||
"CIRCLE": 18,
|
||||
"LINE": 25,
|
||||
"LWPOLYLINE": 4,
|
||||
"MULTILEADER": 8,
|
||||
"TEXT": 26
|
||||
},
|
||||
"layout_live": {
|
||||
"CIRCLE": 18,
|
||||
"LINE": 25,
|
||||
"LWPOLYLINE": 4,
|
||||
"MULTILEADER": 8,
|
||||
"TEXT": 26
|
||||
}
|
||||
},
|
||||
"paper_space_layouts": [],
|
||||
"takeoffs": {
|
||||
"scale_verdict": "schematic",
|
||||
"within_10pct": 0.21818181818181817,
|
||||
"runs_checked": 9,
|
||||
"pipe_mismatch": [],
|
||||
"loads_checked": 4,
|
||||
"cable_mismatch": []
|
||||
},
|
||||
"diff": {
|
||||
"moved": "44",
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"changed": 1,
|
||||
"moved_is_the_change": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The smoke works only in documents it brought in from its own temporary
|
||||
folder, known by full path; each live snapshot, the DWG save and the move
|
||||
first check, in the same COM-thread call, that the active document is the
|
||||
smoke's own, and on the way out it re-activates the document that was active
|
||||
when it started.
|
||||
|
||||
## [1.5.1] — 2026-08-06
|
||||
|
||||
A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~166 tools, 8 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
|
||||
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~247 tools, 8 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
|
||||
|
||||
## Running the Server
|
||||
|
||||
@@ -33,6 +33,9 @@ pip install -e ".[com]"
|
||||
# With PDF/screenshot support via matplotlib
|
||||
pip install -e ".[pdf]"
|
||||
|
||||
# XLSX workbooks (takeoffs, data_extract) via openpyxl
|
||||
pip install -e ".[office]"
|
||||
|
||||
# Everything
|
||||
pip install -e ".[full]"
|
||||
```
|
||||
@@ -93,26 +96,28 @@ Tools are organized into sections (counts are indicative — `system_about` is a
|
||||
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.
|
||||
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. The four feature tables (DIN 471/472, DIN 509, DIN 332, ISO 3601-2) ship with **no rows** in 1.6: their drawing code and provenance tests exist, every size is refused by name, and filling one is a data edit described in its module docstring.
|
||||
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.
|
||||
25. Architecture (12 tools, pack `arch`): `arch_wall`, `arch_opening`, `arch_stair`, `arch_room`, `arch_rooms_detect`, `arch_schedule`, `arch_grid`, `arch_symbol`, `arch_catalogue_list`, `arch_catalogue_insert`, `arch_dimension_chains`, `arch_plan_from_spec`. One plan model — a wall network (axis polyline, thickness, justification, material) with hosted doors and windows — and one engine: L / T / X junctions resolved, openings cut, cut faces hatched by material (`engineering/arch/materials.py`: ISO 128-50 general hatching for brick, the JIS A 0150 RC / stone / wood symbols, AutoCAD's AR-CONC / AR-SAND, AAC as a declared convention), and anything it cannot place listed in `omitted: [{element, reason}]`, never dropped. Rooms are *computed*: a label's area is the planar face its point lies in, and `arch_rooms_detect` reads rooms from any plan (confidence 1.0 for our walls, 0.6 for plain lines) without modifying it. Schedules are a read of the `ACADMCP_ARCH` records, drawn as real TABLEs. Labels and schedules speak English, or Turkish with `lang="tr"` (decimal comma). The COM paths were executed live once by `scripts/smoke_arch_com.py` (AutoCAD 2026).
|
||||
26. Understanding & QA (4 tools, pack `core`): `drawing_understand` (one read-only report of a drawing somebody else made — declared vs inferred units with the evidence, declared vs robust extents and the outlier handles behind them, clusters (sheets, plan copies), every layer's likely discipline and service from an EN / TR / RU / NL / DE vocabulary with a confidence and the deciding keyword, equipment tags, rooms, text languages, blocks, layouts, title-block attributes), `drawing_scale_check` (a drawing against a reference: tags unique within a cluster are matched — a tag seen twice is `ambiguous`, never picked — pairs at least 2 m apart, the median ratio, IQR and the share within ±10 %: `to_scale` at 80 % or more, with its factor; `schematic` at 50 % or less; `partly` between), `drawing_diff` (two revisions matched by signature, then handle, then type + layer + nearest position; `markup=True` draws revision clouds on the newer one — its only write) and `drawing_topology_check` (dangling ends, near misses, interior crossings; the three `topo_*` critique focuses run the same engine under `focus=None` on layers no semantic focus covers). Every one reads one snapshot per drawing (`engineering/understand/snapshot.py`: `ezdxf` directly, or on a live seat `Document.Export(<tmp>/snap, "DXF", ...)`, which never renames the document and carries model space, paper space and block definitions — measured).
|
||||
27. Plant takeoffs (2 tools, pack `plant`): `pipe_takeoff` (topology from the P&ID — segments merged at their ends, split at T-junctions, continued through inline fittings, a double-drawn segment counted once; service from the layer overridden by supply / return words; sizes kept as drawn — `Ø51`, `SMS51`, `DN20` are never equated — direct, by continuity or `unassigned`; lengths from the layout: the rectilinear MST of the tags a run connects, labelled an estimate, the P&ID length kept beside it as a comparison; statuses A / B / C) and `cable_takeoff` (one row per load: power from the P&ID's electrical texts, never invented; the panel from the layout's `Wiring to CPn` callouts; the Manhattan length plus the allowance rounded up to the metre; a section only from a caller's `section_rules`). Both write a workbook (Metraj / Özet / Kontrol / Metodoloji, `lang` tr / en / ru, XLSX with the `office` extra, CSV always) and never the drawing. `scripts/field_test_takeoff.py` runs both on a user's own pair and writes next to it, never into this repository. The COM paths were executed live once by `scripts/smoke_understand_com.py` (AutoCAD 2026).
|
||||
|
||||
**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` = 64 curated drafting tools (54 with `TOOL_PACKS=core`, which drops the three `pid_*`, the three `mech_*` and the four `arch_*` 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 profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` = 65 curated drafting tools (55 with `TOOL_PACKS=core`, which drops the three `pid_*`, the three `mech_*` and the four `arch_*` lean members, and keeps `drawing_understand`; 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,mech,arch` (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, `arch` the 12 SECTION 25 architectural 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 packs:** `TOOL_PACKS=all|core,pid,settings,mech,arch,plant` (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, `arch` the 12 SECTION 25 architectural tools, `plant` the two SECTION 27 takeoffs (track D's Plant 3D readers join it); SECTION 18/19/24/26 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.
|
||||
|
||||
**ISO 129 tolerances:** `dimension_linear` / `dimension_radius` / `dimension_diameter` take `tol_upper` / `tol_lower` / `tol_mode` (`symmetric` ± / `deviation` +a/-b / `limit` / `basic`) and `text_override` (e.g. `⌀20 H7`). Prefer these over hand-drawn tolerance text.
|
||||
|
||||
**ISO 286 fits:** the same dimension tools take `fit="H7"` (mutually exclusive with `tol_*`) — deviations are resolved from authored ISO 286 tables (`engineering/fits.py`; shafts d/e/f/g/h/js/k/m/n/p, holes D/E/F/G/H/JS, sizes 1–500 mm) against the measured nominal and the fit code is appended to the dimension text.
|
||||
**ISO 286 fits:** the same dimension tools take `fit="H7"` (mutually exclusive with `tol_*`) — deviations are resolved from authored ISO 286 tables (`engineering/fits.py`; shafts d/e/f/g/h/js/k/m/n/p and interference r/s/t/u, holes D/E/F/G/H/JS and K/M/N/P by the ISO 286 delta rule, sizes 1–500 mm) against the measured nominal and the fit code is appended to the dimension text.
|
||||
|
||||
**Drawing score:** `drawing_finalize` returns `payload["score"]` — a 0-100 scalar + `invalidity_ratio` + A-F `grade` over the validator + critique union. Use it as the objective quality metric.
|
||||
|
||||
**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_v6.py` is the current set (19 tasks; `--matrix v5` / `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`, 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) or the v6 `arch_roundtrip` (a two-room plan drawn by `arch_plan_from_spec`, its rooms read back by `arch_rooms_detect` within 0.1 % of the label and of the hand-computed net floor, the schedules listing the drawn tags, gated like `mech_assembly`) — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (43 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_v7.py` is the current set (21 tasks; `--matrix v6` / `v5` / `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`, 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) or the v6 `arch_roundtrip` (a two-room plan drawn by `arch_plan_from_spec`, its rooms read back by `arch_rooms_detect` within 0.1 % of the label and of the hand-computed net floor, the schedules listing the drawn tags, gated like `mech_assembly`) or the v7 `takeoff_roundtrip` / `understand_foreign` (the synthetic plant pair of `tests/fixtures/plant_pair.py` read without its truth: the P&ID called schematic, every routable run's layout length equal to the rectilinear MST of its tags, the cable metres rounded up after the allowance; units, the outlier, both plan copies and the service layers found by one `describe`) — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (48 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.json`; `scripts/check_doc_numbers.py` names it in `AB_REPORT`).
|
||||
|
||||
### Adding a New Tool
|
||||
|
||||
@@ -139,7 +144,7 @@ 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,mech,arch`** advertises only the packs a client uses; `core` is always on; `system_about` reports `tool_packs`.
|
||||
- **`TOOL_PACKS=all|core,pid,settings,mech,arch,plant`** 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).
|
||||
@@ -153,6 +158,11 @@ Tools are organized into sections (counts are indicative — `system_about` is a
|
||||
- **Plan text follows the plot scale**: `scale` is a denominator (50 = 1:50) and a paper height `h` is drawn at `h × scale` drawing units — no annotative objects. `lang="tr"` switches words and the decimal separator; any other language is refused with the list.
|
||||
- **Three `arch_*` critique focuses** (`arch_room_unlabelled`, `arch_wall_gap` — two wall ends within 50 mm that do not join, `arch_opening_clash`) run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing with no `ACADMCP_ARCH` record.
|
||||
- **Nominal, not standard**: the furniture and sanitary blocks carry nominal catalogue dimensions and say so; the stair tool reports the Blondel relation (2R + G within 600–650 mm) as a design rule, never as a building code; the `arch` layer colours are a repository convention (ISO 13567 fixes names, not colours).
|
||||
- **Read-only by default (track H)**: `drawing_understand`, `drawing_scale_check`, `drawing_topology_check` and both takeoffs never modify the drawing; `drawing_diff` writes only with `markup=True`, and a takeoff writes a workbook file, never the drawing.
|
||||
- **Lengths come from the inferred unit**: a reader measures in the unit the geometry implies (text heights, wall and door sizes, overall size) and reports metres; a disagreement with `INSUNITS` is a warning with the numbers — a layout declaring inches over millimetre geometry is measured in millimetres, never ×25.4.
|
||||
- **Scale check before a P&ID length**: a P&ID is a length source only when `drawing_scale_check` calls it `to_scale`; otherwise a run's length is the orthogonal estimate on the layout and the drawn length stays a comparison column (`length_source="pid"` on a schematic P&ID is refused unless `force=True`, quoting the statistics).
|
||||
- **Nothing is invented (track H)**: a power the P&ID does not state stays empty and becomes an open item, an unreadable size stays `unassigned`, and no cable-section table is authored — a section comes only from the caller's `section_rules`.
|
||||
- **Client drawings never enter the repository**: every track-H fixture is the synthetic plant pair (`tests/fixtures/plant_pair.py`), whose truth is computed from the positions it placed; `scripts/field_test_takeoff.py` refuses an output folder inside the repository and prints no quantity unless `--show` is given.
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# AutoCAD MCP Pro — как у нас развёрнут и как обновлять
|
||||
|
||||
Этот репозиторий — **зеркало** открытого проекта `U-C4N/Autocad-MCP` (PyPI-пакет
|
||||
`autocad-mcp-pro`), которым мы пользуемся для черчения из агентов Hermes.
|
||||
|
||||
## 1. Источники версий и обновлений (единственные авторитетные)
|
||||
|
||||
| Что | Где смотреть |
|
||||
|---|---|
|
||||
| Исходный репозиторий | https://github.com/U-C4N/Autocad-MCP |
|
||||
| Релизы и теги (что нового) | https://github.com/U-C4N/Autocad-MCP/releases — теги `v1.3.0 … v1.6.0` |
|
||||
| Список изменений | `CHANGELOG.md` в корне репозитория (самый подробный источник) |
|
||||
| PyPI-пакет и доступные версии | https://pypi.org/pypi/autocad-mcp-pro/json (поле `info.version`, `releases`) |
|
||||
| Внутренняя версия сервера | MCP-вызов `system_about` / ответ `initialize` (`serverInfo.version`) |
|
||||
|
||||
**Текущий пин зеркала:** ветка `main`, коммит `cdb1063` (после релиза **1.6.0 от 24.09.2026**).
|
||||
Релизы: `v1.5.1` → `v1.6.0` (247 зарегистрированных инструментов, 242 отдаётся по умолчанию).
|
||||
|
||||
Обновления проверять так (без установки):
|
||||
|
||||
```bash
|
||||
# последняя версия на PyPI
|
||||
curl -s https://pypi.org/pypi/autocad-mcp-pro/json | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['info']['version'], list(d['releases'])[-4:])"
|
||||
# свежие релизы на GitHub
|
||||
curl -s https://api.github.com/repos/U-C4N/Autocad-MCP/releases | python3 -c "import json,sys;[print(r['tag_name'], r['published_at']) for r in json.load(sys.stdin)[:5]]"
|
||||
```
|
||||
|
||||
## 2. Как это работает у нас (архитектура)
|
||||
|
||||
```
|
||||
Hermes (Docker, VM108) --HTTP MCP (Bearer)--> контейнер autocad-mcp :30015 --ezdxf--> DXF/PDF
|
||||
контейнер autocad-mcp-160 :30016 (тестовый 1.6.0)
|
||||
папка обмена: /var/lib/docker/volumes/hermes_hermes-data/_data/dxf → /opt/data/dxf
|
||||
```
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Рабочий контейнер | `autocad-mcp`, образ `autocad-mcp:latest` = **1.5.1 + наш патч**, порт **30015 → 8000** |
|
||||
| Тестовый контейнер | `autocad-mcp-160`, образ `autocad-mcp:1.6.0` (апстрим без патча), порт **30016** |
|
||||
| Бэкенд | `AUTOCAD_MCP_BACKEND=ezdxf` (headless, без AutoCAD; COM — только на Windows рядом с AutoCAD) |
|
||||
| Токен доступа | файл `/opt/data/.autocad_mcp_token` (chmod 600, **в git не попадает**), передаётся как `Authorization: Bearer …` |
|
||||
| Папка файлов | `ALLOWED_PATHS=/opt/data/dxf` — сервер принимает файлы **только** оттуда (иначе «Path rejected») |
|
||||
| Профиль инструментов | `TOOL_PROFILE=full`; для слабых моделей есть `TOOL_PACKS=core,mech...` и `DISCOVERY_MODE=search` (2 инструмента / 356 токенов вместо 83 921) |
|
||||
| Деплой-скрипты | `/opt/data/deploy_autocad_mcp.py` (рабочий, с тегом отката `autocad-mcp:prev`), `/opt/data/work/cad_mcp/deploy_160_test.py` (тестовый 1.6.0) |
|
||||
| Логи сборки | `/opt/data/autocad_mcp_build.log`, `/opt/data/autocad_mcp_160_build.log` |
|
||||
| Проверка живости | `python3 /opt/data/work/cad_mcp/tools_inventory.py 30015` (делает `initialize` → `tools/list`) |
|
||||
|
||||
## 3. Что в нашей рабочей сборке сверх апстрима (патч 01.10.2026)
|
||||
|
||||
Оверлей лежит в `/opt/data/autocad-mcp-build/patch/` и копируется поверх `site-packages`
|
||||
(см. `Dockerfile` в `/opt/data/autocad-mcp-build/`). Этих инструментов в апстриме нет:
|
||||
|
||||
| Инструмент | Зачем |
|
||||
|---|---|
|
||||
| `titleblock_apply_gost_form3` | основная надпись по **ГОСТ Р 21.1101-2013 форма 3** (апстрим знает только ISO 7200) |
|
||||
| `file_put` / `file_get` | передача файлов внутрь `ALLOWED_PATHS` и обратно (без Docker-томов и HTTP-моста) |
|
||||
| `drawing_open_base64` / `drawing_save_base64` | обмен чертежами в base64 |
|
||||
| `entity_create_attdef`, `block_get_attdefs` | символы-блоки с атрибутами (тип/высота/группа) |
|
||||
| параметр `layout=` у инструментов **геометрии** (29 инструментов) | рисовать линии/полилинии прямо на ЛИСТЕ, а не только в модели |
|
||||
| параметр `inline_font=True` (5 инструментов) | инлайн-кодировка ISOCPEUR в каждом тексте |
|
||||
| `drawing_export_pdf(page_size="layout")` | страница PDF ровно по формату листа (в 1.5.1 апстрим подгонял по содержимому) |
|
||||
| `layout_set_paper` | явный размер бумаги листа (A4 книжный и др.) |
|
||||
| `template_apply_layers(spds)` | ГОСТ-набор слоёв (RAMKA, STAMPS, 00 БЛОКИ АННОТАЦИЙ, Стены) |
|
||||
| `dimstyle_ensure`, `text_apply_style` | размерный стиль СПДС, текстовые стили |
|
||||
|
||||
⚠️ **Следствие:** апстрим 1.6.0 нельзя просто «поставить вместо» — часть наших возможностей
|
||||
пропадёт (ГОСТ-штамп, file_put/file_get, `layout` у геометрии, `inline_font`). Поэтому 1.6.0
|
||||
поднимается **рядом** (порт 30016), а перенос патча делается отдельным шагом.
|
||||
|
||||
## 4. Установка с нуля
|
||||
|
||||
```bash
|
||||
# 1) образ (Docker-контекст: Dockerfile + patch/)
|
||||
mkdir -p /opt/data/autocad-mcp-build && cd /opt/data/autocad-mcp-build
|
||||
# Dockerfile:
|
||||
# FROM python:3.11-slim
|
||||
# RUN pip install --no-cache-dir autocad-mcp-pro==1.5.1 matplotlib
|
||||
# COPY patch/ /usr/local/lib/python3.11/site-packages/
|
||||
# EXPOSE 8000
|
||||
# CMD ["autocad-mcp","--transport","http","--host","0.0.0.0","--port","8000"]
|
||||
|
||||
# 2) сборка и переустановка контейнера (Portainer API, безопасно: сначала образ, потом замена)
|
||||
python3 /opt/data/deploy_autocad_mcp.py # рабочий (с тегом отката autocad-mcp:prev)
|
||||
python3 /opt/data/work/cad_mcp/deploy_160_test.py # тестовый 1.6.0 на :30016
|
||||
|
||||
# 3) подключение в Hermes (конфиг защищён, менять через config set)
|
||||
export PATH="/opt/hermes/.venv/bin:$PATH" HERMES_HOME=/opt/data HOME=/opt/data
|
||||
hermes config set mcp_servers.autocad.type http
|
||||
hermes config set mcp_servers.autocad.url http://10.26.5.108:30015/mcp
|
||||
hermes config set mcp_servers.autocad.headers.Authorization "Bearer <токен из /opt/data/.autocad_mcp_token>"
|
||||
hermes mcp test autocad # ожидаем "Connected" + число инструментов
|
||||
|
||||
# 4) проверка
|
||||
python3 /opt/data/work/cad_mcp/tools_inventory.py 30015
|
||||
python3 /opt/skills/devops/cad-automation/scripts/mcp_acceptance_test.py # приёмочный прогон
|
||||
```
|
||||
|
||||
## 5. Обновление версии
|
||||
|
||||
1. Проверить новую версию (п. 1) и прочитать `CHANGELOG.md`.
|
||||
2. Собрать **тестовый** образ на новую версию: скопировать
|
||||
`/opt/data/autocad-mcp-build/Dockerfile` в новый каталог, заменить пин версии,
|
||||
запустить `deploy_160_test.py` (порт 30016).
|
||||
3. Прогнать инвентаризацию и приёмку по обоим портам, сравнить:
|
||||
`tools_inventory.py 30015` против `tools_inventory.py 30016` — что появилось, что пропало.
|
||||
4. Перенести наш патч (см. п. 3) либо решить, что из апстрима заменяет нашу доработку.
|
||||
5. Переключить рабочий контейнер (`deploy_autocad_mcp.py`) — он сам ставит тег отката
|
||||
`autocad-mcp:prev`; при проблеме вернуться на него.
|
||||
|
||||
**Откат:** остановить контейнер, создать заново из образа `autocad-mcp:prev` с теми же
|
||||
параметрами (порт 30015 → 8000, `ALLOWED_PATHS=/opt/data/dxf`, тот же токен, User 10000:10000).
|
||||
|
||||
## 6. Грабли, которые уже собирали (не повторять)
|
||||
|
||||
- `tools/list` «сырым» POST даёт **HTTP 400** — нужен `initialize` → заголовок `Mcp-Session-Id` → `tools/list`.
|
||||
- Файлы внутрь контейнера: только путь из `ALLOWED_PATHS` (`/opt/data/dxf`), иначе «Path rejected».
|
||||
- У контейнера **свой том** — это не тот `/opt/data/dxf`, который видит агент; для передачи
|
||||
файлов используем `file_put`/`file_get` или мост `dxf-bridge` (`http://10.26.5.108:30055/`).
|
||||
- PDF: проверять медиабокс (`pymupdf` → `page.rect.width/72*25.4` ≈ формат листа) и шрифт (`ISOCPEUR`).
|
||||
- Чертёж отдавать пользователю только после проверки движком AutoCAD (`accoreconsole /i … /s …`).
|
||||
- COM-режим (живой AutoCAD) **невозможен из Docker** — сервер обязан работать на Windows рядом с AutoCAD.
|
||||
@@ -11,44 +11,72 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
[](https://github.com/U-C4N/Autocad-MCP/blob/main/pyproject.toml)
|
||||
[](https://github.com/U-C4N/Autocad-MCP/blob/main/LICENSE)
|
||||
|
||||
[Install](#install) · [Tools](#what-you-get) · [Engines](#the-two-engines) · [Evidence](#evidence) · [Limits](#what-this-release-is-bad-at) · [Config](#configuration) · [Changelog](https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md)
|
||||
[Install](#install) · [What's new](#whats-new-in-16) · [Tools](#what-you-get) · [Engines](#the-two-engines) · [Evidence](#evidence) · [Limits](#what-this-release-is-bad-at) · [Config](#configuration) · [Changelog](https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-showcase.png" alt="ISO A3 sheet: a module-6, 24-tooth spur gear with a keyed bore, its section A-A, ISO 129 dimensions with an H7 bore fit, and an ISO 7200 title block" width="800">
|
||||
|
||||
<sub><b>Not a mockup.</b> Every line on this sheet was drawn by the tools this server exposes — ISO layers, involute gear geometry, DIN 6885 keyway, section A-A, ISO 129 dimensions, an ISO 286 <code>H7</code> bore fit, ISO 7200 title block — then rendered headlessly by <code>view_screenshot</code>. <code>drawing_critique</code> returns <b>0 issues</b> on it. Rebuild it with <code>python scripts/render_readme_showcase.py</code>.</sub>
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/pid-showcase.png" alt="P&ID sheet: a centrifugal pump, a diaphragm-actuated globe control valve, a vertical vessel with two nozzles, a flow controller bubble and an off-page connector, joined by numbered process lines and an electric signal line" width="800">
|
||||
|
||||
<sub><b>The same server as a P&ID drafter.</b> <code>pid_from_spec</code> placed five catalogue symbols as real blocks with <code>TAG</code> attributes and named ports, routed four port-to-port lines with ISA-5.1 classes and line numbers in one transaction, then <code>pid_graph</code> read the sheet back: 5 nodes, 4 edges, 0 dangling ends, <code>confidence_min</code> 1.0. Rebuild it with <code>python scripts/render_readme_pid.py</code>.</sub>
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/pid-catalog.png" alt="Contact sheet of the 43 P&ID catalogue symbols: valve bodies with actuators, pumps and compressors, heat exchangers, vessels, instrument bubbles, line markers and connectors" width="800">
|
||||
|
||||
<sub><b>The catalogue, authored in code.</b> 43 ISO 10628-2 / ISA-5.1 symbols — 11 valve bodies × 6 actuators, 6 rotating machines, 4 heat exchangers, 6 parametric vessels, 7 inline items, the instrument bubble in 4 types × 5 locations, 5 line markers, 3 connectors — every one placed through <code>pid_symbol_insert</code>'s own path. Rebuild it with <code>python scripts/render_pid_catalog.py</code>.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 247 tools · 8 resources · 5 prompt templates · 4942 collected tests.
|
||||
> **v1.6 release snapshot:** 247 tools · 8 resources · 5 prompt templates · 4992 collected tests.
|
||||
> 247 is the **registered** count; a default install advertises 242 over `tools/list`,
|
||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||
|
||||
## What's new in 1.6
|
||||
|
||||
Five tracks of one [roadmap](https://github.com/U-C4N/Autocad-MCP/blob/main/docs/superpowers/specs/2026-09-15-v1.6-roadmap-design.md), 154 → 247 registered tools. Each track has its own design spec, correctness checks that can fail, and a live AutoCAD 2026 run of its COM paths recorded in the [changelog](https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md).
|
||||
|
||||
| Track | What it adds | Pack |
|
||||
|---|---|---|
|
||||
| **P&ID — drafter and reader** | 43 ISO 10628-2 / ISA-5.1 symbols as real blocks with ports and `TAG` attributes, port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reading any P&ID back with a confidence per node, instrument index, line list, equipment list, `pid_from_spec` for a whole sheet in one transaction | `pid` |
|
||||
| **The AutoCAD environment** | ISO-25 / ANSI dimension styles, text and leader styles, `drawing_apply_standard`, ISO 216 / ANSI page setups checked against the plotted PDF's own `/MediaBox`, five bundled templates, several open documents headlessly, portable layer states, named views, UCS, a 90-entry system-variable catalogue; on a live seat, preferences, launch and operator prompts | `core`, `settings` |
|
||||
| **Mechanical parts and the sheet** | one part model and one view engine (front, side, top, section and detail views with hidden lines, cut faces and ISO 129 dimensions), ISO fasteners and bearings as blocks with attributes, ISO 21920-1 surface texture, ISO 2553 welds, ISO 5457 frames A4–A0, ISO 7200 title blocks, an ISO 7573 parts list read off the drawing with linked ISO 6433 balloons, xrefs, images, real `.dwg` | `mech`, `core` |
|
||||
| **Architecture** | walls with hosted doors and windows: one engine resolves L / T / X junctions and cuts the openings. Room areas are measured from the faces the walls enclose, and rooms are read back from any plan. Door / window / room schedules are real tables; labels in English or Turkish | `arch` |
|
||||
| **Understanding and takeoffs** | `drawing_understand` reads a drawing somebody else made in one call. Also `drawing_scale_check`, `drawing_diff` and `drawing_topology_check`. `pipe_takeoff` / `cable_takeoff` take the topology from the P&ID and the lengths from the layout into a Turkish, English or Russian workbook | `core`, `plant` |
|
||||
|
||||
`TOOL_PACKS` keeps a client from paying for the tracks it does not use ([idle cost below](#why-this-exists)).
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/mech-showcase.png" alt="ISO A3 flange-coupling sheet: a hub with a full section and chain dimensions, a flange with its bolt-circle end view, two ISO 4014 bolts with ISO 4032 nuts, a parts list with linked balloons, an ISO 5457 frame with zones and an ISO 7200 title block" width="800">
|
||||
|
||||
<sub><b>A mechanical sheet, gated.</b> The <code>mech_assembly</code> benchmark sheet: a hub drawn from its part model with a full section and chain dimensions, a flange with its bolt-circle end view, two ISO 4014 M12×60 bolts and ISO 4032 nuts inserted as blocks, the ISO 7573 parts list <i>read off the drawing</i>, balloons linked to its rows, an ISO 5457 frame and an ISO 7200 title block. <code>drawing_critique(focus=None)</code> returns <b>0 issues</b> and the finalize score is <b>100</b>. Rebuild it with <code>python scripts/render_readme_mech.py</code>.</sub>
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/arch-showcase.png" alt="Two-room floor plan: hatched brick walls with an aerated-concrete partition, two doors, two windows, a straight stair, room labels with measured areas, exterior dimension chains, and door, window and room schedules" width="800">
|
||||
|
||||
<sub><b>A floor plan whose areas nobody typed.</b> <code>arch_plan_from_spec</code>'s example, drawn in one transaction: a 250 mm brick ring split by a 100 mm AAC wall, two doors, two windows, a straight stair and exterior dimension chains at 1:50. The rooms measure 27.74 and 21.99 m², read from the faces the walls enclose, and the door, window and room schedules are read off the drawing. <code>drawing_critique(focus=None)</code> returns <b>0 issues</b>. Rebuild it with <code>python scripts/render_readme_arch.py</code>.</sub>
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/pid-showcase.png" alt="P&ID sheet: a centrifugal pump, a diaphragm-actuated globe control valve, a vertical vessel with two nozzles, a flow controller bubble and an off-page connector, joined by numbered process lines and an electric signal line" width="800">
|
||||
|
||||
<sub><b>The same server as a P&ID drafter.</b> <code>pid_from_spec</code> placed five catalogue symbols as real blocks with <code>TAG</code> attributes and named ports and routed four port-to-port lines with ISA-5.1 classes and line numbers, all in one transaction. Then <code>pid_graph</code> read the sheet back: 5 nodes, 4 edges, 0 dangling ends, <code>confidence_min</code> 1.0. Rebuild it with <code>python scripts/render_readme_pid.py</code>.</sub>
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/pid-catalog.png" alt="Contact sheet of the 43 P&ID catalogue symbols: valve bodies with actuators, pumps and compressors, heat exchangers, vessels, instrument bubbles, line markers and connectors" width="800">
|
||||
|
||||
<sub><b>The catalogue, authored in code.</b> 43 ISO 10628-2 / ISA-5.1 symbols: 11 valve bodies × 6 actuators, 6 rotating machines, 4 heat exchangers, 6 parametric vessels, 7 inline items, the instrument bubble in 4 types × 5 locations, 5 line markers and 3 connectors. Every one was placed through <code>pid_symbol_insert</code>'s own path. Rebuild it with <code>python scripts/render_pid_catalog.py</code>.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
## Why this exists
|
||||
|
||||
**A big MCP server is expensive to be connected to.** The full catalog costs a client **78,772 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 **83,921 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 **206 AutoCAD command names and 1439 synonym phrases** covering all 247 tools. A test refuses to let a tool exist without one.
|
||||
**A drafter searches for `FILLET`, not `entity_fillet`.** Before the corpus, those command names appeared in no tool name or description. That was `df = 0` against a stock index: the tools were not ranked badly, they were *absent*. The fix was data: an authored corpus of **206 AutoCAD command names and 1439 synonym phrases** covering all 247 tools. A test refuses to let a tool exist without one.
|
||||
|
||||
| Advertised surface | Tools seen | Idle cost |
|
||||
|---|---:|---:|
|
||||
| `TOOL_PROFILE=full` (default) | 236 | 78,772 tokens |
|
||||
| `TOOL_PACKS=core,settings` (full profile) | 201 | 60,926 tokens |
|
||||
| `TOOL_PACKS=core,mech` (full profile) | 193 | 61,872 tokens |
|
||||
| `TOOL_PACKS=core,arch` (full profile) | 191 | 60,568 tokens |
|
||||
| `TOOL_PACKS=core` (full profile) | 179 | 54,158 tokens |
|
||||
| `TOOL_PROFILE=lean` | 64 | 21,939 tokens |
|
||||
| `TOOL_PROFILE=full` (default) | 242 | 83,921 tokens |
|
||||
| `TOOL_PACKS=core,settings` (full profile) | 205 | 63,890 tokens |
|
||||
| `TOOL_PACKS=core,mech` (full profile) | 197 | 64,836 tokens |
|
||||
| `TOOL_PACKS=core,arch` (full profile) | 195 | 63,549 tokens |
|
||||
| `TOOL_PACKS=core,pid` (full profile) | 192 | 60,844 tokens |
|
||||
| `TOOL_PACKS=core,plant` (full profile) | 185 | 59,290 tokens |
|
||||
| `TOOL_PACKS=core` (full profile) | 183 | 57,122 tokens |
|
||||
| `TOOL_PROFILE=lean` | 65 | 22,661 tokens |
|
||||
| `DISCOVERY_MODE=search` | 2 | **356 tokens** |
|
||||
|
||||
> [!NOTE]
|
||||
> Offline ratio estimates, not tokenizer counts, and the *uncached* ratio — prompt caching amortises the idle term. `benchmarks/token_suite.py --tokenizer anthropic` counts for real.
|
||||
> These are offline ratio estimates, not tokenizer counts, and they are the *uncached* cost: prompt caching amortises the idle term. `benchmarks/token_suite.py --tokenizer anthropic` counts for real.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -67,6 +95,7 @@ AUTOCAD_MCP_BACKEND=com autocad-mcp # live AutoCAD (needs the [com] extra)
|
||||
| *(none)* | `fastmcp`, `ezdxf`, `pydantic` | Headless DXF on any OS — **no rendering** |
|
||||
| `[com]` | `pywin32`, `Pillow` | Live AutoCAD control + window capture |
|
||||
| `[pdf]` | `matplotlib` | PDF export and headless PNG, any platform |
|
||||
| `[office]` | `openpyxl` | XLSX workbooks (takeoffs, `data_extract`); CSV is always written |
|
||||
| `[full]` | everything above | Development and CI |
|
||||
|
||||
> [!NOTE]
|
||||
@@ -84,6 +113,7 @@ AUTOCAD_MCP_BACKEND=com autocad-mcp # live AutoCAD (needs the [com] extra)
|
||||
"AUTOCAD_MCP_BACKEND": "auto",
|
||||
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD",
|
||||
"TOOL_PROFILE": "full",
|
||||
"TOOL_PACKS": "all",
|
||||
"DISCOVERY_MODE": "off"
|
||||
}
|
||||
}
|
||||
@@ -91,7 +121,7 @@ AUTOCAD_MCP_BACKEND=com autocad-mcp # live AutoCAD (needs the [com] extra)
|
||||
}
|
||||
```
|
||||
|
||||
Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transport http --port 8000` — loopback only unless remote HTTP is explicitly enabled **and** a bearer token is set.
|
||||
It works with Claude Desktop, Cursor, or any stdio MCP host. For HTTP, run `autocad-mcp --transport http --port 8000`. It binds loopback only, unless remote HTTP is explicitly enabled **and** a bearer token is set.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -101,34 +131,38 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
|
||||
|---|---|
|
||||
| Drawing lifecycle | create, open, save, export DXF/PDF, audit *(repairs)*, purge, undo/redo |
|
||||
| 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) |
|
||||
| Annotation | ISO 129 toleranced dimensions, ISO 286 fits (`fit="H7"`, interference shafts r/s/t/u included), 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 |
|
||||
| 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 and ISO 15 bearings insert as real blocks with attributes. DIN 471/472, DIN 509, DIN 332 and ISO 3601-2 features have their drawing code, but *their tables ship empty in 1.6* (see [limits](#what-this-release-is-bad-at)). Also ISO 21920-1 surface texture, ISO 2553 welds and 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 |
|
||||
| Architecture | one plan model — walls with hosted doors and windows — and one engine: L / T / X junctions resolved, openings cut, poché by material; rooms measured from the faces the walls enclose (never typed) and read back from any plan with a confidence; door / window / room schedules as real TABLEs; a furniture and sanitary catalogue at nominal sizes; structural grid, north arrow, section / level / elevation marks; exterior dimension chains; English or Turkish labels (`lang="tr"`, decimal comma) |
|
||||
| Architecture | one plan model (walls with hosted doors and windows) and one engine that resolves L / T / X junctions, cuts openings and draws poché by material. Room areas are measured from the faces the walls enclose, never typed, and rooms are read back from any plan with a confidence. Door / window / room schedules are real TABLEs. Also a furniture and sanitary catalogue at nominal sizes, a structural grid, north arrow, section / level / elevation marks, exterior dimension chains, and English or Turkish labels (`lang="tr"`, decimal comma) |
|
||||
| Understanding & takeoffs | `drawing_understand` reads a drawing somebody else made in one call: declared vs inferred units, robust extents and the outlier behind them, plan copies, each layer's discipline and service in five languages, tags, rooms. `drawing_scale_check` says whether a P&ID is to scale against its layout. `pipe_takeoff` / `cable_takeoff` take the topology from the P&ID and the lengths from the layout (rectilinear MST, statuses A/B/C, power never invented) into a Turkish / English / Russian workbook. `drawing_diff` compares revisions; `drawing_topology_check` finds dangling ends, near misses and crossings |
|
||||
| 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 242-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) |
|
||||
| Environment | several open documents headlessly, portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager), named views, UCS *(tool coordinates stay WCS)*, a system-variable catalog with ranges, document properties, and on a live seat: launch/attach, preferences, an operator prompt / pick / select — where ESC is `cancelled`, not an error |
|
||||
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` |
|
||||
| Page setup & templates | ISO 216 / ANSI Y14.1 sheets, ctb catalog, `page_setup_apply` on both engines, and `batch_plot` with every sheet size read back from its PDF's `/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) |
|
||||
| Environment | several open documents headlessly, portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager), named views, UCS *(tool coordinates stay WCS)*, a system-variable catalog with ranges, document properties. On a live seat: launch/attach, preferences, and an operator prompt / pick / select, where ESC is `cancelled`, not an error |
|
||||
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
|
||||
| Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* |
|
||||
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
|
||||
| Hatch depth | gradients, in-place edits, typed edge boundaries *(headless)*, island styles |
|
||||
| Annotation objects | WIPEOUT, REVCLOUD, MTEXT background masks *(headless)*, text find/replace |
|
||||
| 3D solids | `solid_box/cylinder/extrude/revolve/boolean` on live AutoCAD (`ENABLE_3D=true`) |
|
||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score); 18 domain critique focuses (P&ID 6, mechanical 6, architecture 3, topology 3), each silent on a drawing outside its domain |
|
||||
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
||||
|
||||
<sub>247 tools in 28 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools, the 12 architectural tools and the two plant takeoffs (<code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones, <code>TOOL_PACKS=core,plant</code> the takeoffs) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
<sub>247 tools in 28 groups. <code>TOOL_PACKS=core</code> hides these from a client that needs none of them: the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools, the 12 architectural tools and the two plant takeoffs. <code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones, and <code>TOOL_PACKS=core,plant</code> the takeoffs. There are also 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
|
||||
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And 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 states its own accuracy.
|
||||
**Two rules worth knowing.**
|
||||
|
||||
- Every coordinate in and out of a tool is WCS on both engines. The one exception is TEXT `rotation`, which stays in the entity frame: a mirrored TEXT is mirror-imaged, and no scalar angle expresses that.
|
||||
- Never read vertices back and shoelace them. That silently loses **28.2%** of the area on a semicircular edge. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
||||
|
||||
## The two engines
|
||||
|
||||
One contract across 19 modules in `backends/contracts/`. `@capability(key, reason=…)` supplies a default that *refuses*, and a test holds both backends' capability key sets equal. There are **27 capability keys**; read `system_capabilities` at runtime rather than trusting the table — five of them depend on how the machine is set up.
|
||||
One contract across 23 modules in `backends/contracts/`. `@capability(key, reason=…)` supplies a default that *refuses*, and a test holds both backends' capability key sets equal. There are **41 capability keys**. Read `system_capabilities` at runtime rather than trusting the table: several depend on what the machine has installed (matplotlib, openpyxl, the ODA File Converter).
|
||||
|
||||
| Capability | COM (live AutoCAD) | ezdxf (headless) |
|
||||
|---|:---:|:---:|
|
||||
@@ -142,7 +176,11 @@ One contract across 19 modules in `backends/contracts/`. `@capability(key, reaso
|
||||
| HATCH filled area (islands subtracted) | AutoCAD's own number | Loops walked, `hatch_style` reported |
|
||||
| REGION / 3DSOLID area | ✅ | — *ACIS is opaque to ezdxf* |
|
||||
| 3D solids | ✅ *with `ENABLE_3D=true`* | — |
|
||||
| DWG write | ✅ | — |
|
||||
| DWG write | ✅ `SaveAs` | via the ODA File Converter, if installed |
|
||||
| `.dwt` templates | ✅ | DXF only — `dwt_write` refuses |
|
||||
| Reading a foreign drawing (`drawing_understand`, takeoffs) | ✅ one `Document.Export` snapshot; the document is never renamed | ✅ |
|
||||
| XLSX workbooks | ✅ ᵒ | ✅ ᵒ |
|
||||
| Preferences · launch · operator prompts | ✅ | — *refused by name* |
|
||||
| WIPEOUT · MTEXT background colour | — *verified absent, AutoCAD 2026* | ✅ |
|
||||
| REVCLOUD · BPOLY · typed hatch edges | — *no ActiveX member* | ✅ |
|
||||
| CHSPACE | — *unverified on a live seat* | ✅ ᶜ |
|
||||
@@ -150,78 +188,176 @@ One contract across 19 modules in `backends/contracts/`. `@capability(key, reaso
|
||||
| TABLE and MLEADER | Native | Portable composite |
|
||||
| Screenshots and PDF | Window capture | Matplotlib ᵐ |
|
||||
|
||||
<sub>ᵐ Needs matplotlib (<code>[pdf]</code>/<code>[full]</code>); without it <code>png</code>, <code>pdf</code>, <code>viewport_render</code> and <code>handle_overlay</code> report unsupported headlessly.<br>
|
||||
ᶜ Headless CHSPACE carries four named restrictions in its capability reason: top-view untwisted viewports only, dimensions refused unless frozen, ACIS/proxy/table refused, viewport clipping reported rather than applied.</sub>
|
||||
<sub>ᵐ Needs matplotlib (<code>[pdf]</code>/<code>[full]</code>). Without it, <code>png</code>, <code>pdf</code>, <code>viewport_render</code> and <code>handle_overlay</code> report unsupported headlessly.<br>
|
||||
ᵒ Needs openpyxl (<code>[office]</code>/<code>[full]</code>). Without it, XLSX is refused with <code>xlsx_write</code> and CSV is still written.<br>
|
||||
ᶜ Headless CHSPACE has four restrictions, named in its capability reason: top-view untwisted viewports only; dimensions refused unless frozen; ACIS, proxy and table entities refused; viewport clipping reported rather than applied.</sub>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Every COM path added in v1.5 was executed against a **live AutoCAD 2026**. That run found four defects reading could not — the largest being that `AcadPViewport` has no `ViewCenter` member, so the line setting it had been silently swallowed by a bare `except` since v1.4.
|
||||
> Every COM path added in v1.6 was executed against a **live AutoCAD 2026**, one smoke script per track (`scripts/smoke_*_com.py`), each run recorded in the changelog. Those runs found defects no headless test could. ActiveX refuses `entity.Layer` on a missing layer, where ezdxf quietly creates it, so `mech_part_draw` died after drawing every view. ActiveX calls a block reference `BLOCKREFERENCE`, not `INSERT`, so the parts list read nothing. And once pywin32's type-library cache existed, every listed entity arrived as a bare `IAcadEntity` with no line endpoints, so the room reader saw no walls and `arch_plan_from_spec` refused its first room label.
|
||||
|
||||
## Evidence
|
||||
|
||||
Self-measurement, produced by scripts in [`benchmarks/`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/).
|
||||
Everything below is produced by scripts in [`benchmarks/`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/) and published as JSON under [`benchmarks/results/published/`](https://github.com/U-C4N/Autocad-MCP/tree/main/benchmarks/results/published).
|
||||
|
||||
### Against the other AutoCAD MCP servers
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-benchmark.svg" alt="Source-reviewed capability leaderboard: U-C4N/Autocad-MCP 93, varavista 84, beiming183-cloud 83, best-cad-mcp 74, multiCAD-mcp 73, puran-water 70, NCO-1986 68, daobataotie 35, Easy-MCP-AutoCad 30" width="800">
|
||||
|
||||
A fixed 100-point rubric applied to nine public AutoCAD MCP servers ([`source_review.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/source_review.json)). Stars and raw tool counts score nothing. 1.6.0 is scored **per category, with the evidence for every point withheld**:
|
||||
|
||||
| Category | Weight | 1.6.0 | Points withheld for |
|
||||
|---|---:|---:|---|
|
||||
| Functional CAD coverage | 25 | 24 | no electrical schematics, image-to-CAD tracing, Plant 3D, obstacle-avoiding routing or headless REGION / 3DSOLID |
|
||||
| Correctness and delivery | 20 | 19 | a headless DIMENSION does not re-render; a takeoff on a real P&ID still needs the engineer's scope decisions |
|
||||
| Backends and platforms | 15 | 14 | BricsCAD, ZWCAD and GstarCAD unverified |
|
||||
| Engineering production | 15 | 14 | four DIN / ISO feature tables ship empty |
|
||||
| Tests and maintenance | 15 | 14 | headless entity creation slower than v1.4.0 |
|
||||
| Security and operations | 10 | 8 | `ALLOWED_PATHS` unscoped by default; command filtering is a denylist |
|
||||
| **Total** | **100** | **93** | |
|
||||
|
||||
> [!NOTE]
|
||||
> Read this as a documented self-assessment, not an independent review: this repository applies the rubric to itself and to the others. The competitor rows are their July 2026 snapshots and were not re-reviewed for this release, and the best of them, varavista/autocad-mcp, scored 84. 1.4.0 was given one overall 95. This review is stricter and scores each category apart.
|
||||
|
||||
A second lane runs tasks instead of reading source. Every task in the fixed-task matrix can fail, and its artifacts are verified by re-opening the DXF, never by trusting a response. The reference adapter passes **21 / 21** of matrix v7. The two competitors with adapters, pinned at their July 2026 commits, passed 5 / 10 and 4 / 10 of the ten v2 tasks they were run on. The eleven tasks added since then were never put to them, so they show as *not run*, not as zero:
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-taskmatrix.svg" alt="Task matrix: autocad-mcp-pro passes all 21 tasks; beiming183 and puran-water were run on the first ten, with the later eleven not run" width="800">
|
||||
|
||||
### Correctness — every release re-proves itself
|
||||
|
||||
43 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.
|
||||
48 deterministic headless checks against the previous tag and the current tree. Each runs 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 / 43 | 60.5 % | — | — |
|
||||
| **v1.6.0-dev** *(this branch)* | **43 / 43** | **100 %** | 17 | **0** |
|
||||
| v1.5.1 *(baseline)* | 26 / 48 | 54.2 % | — | — |
|
||||
| **v1.6.0** *(this release)* | **48 / 48** | **100 %** | 22 | **0** |
|
||||
|
||||
The seventeen fixed rows are new capability (`miss → pass`). Track F adds four: `arch_junction_l_t_x` (an L corner mitres at (4100, −100) / (3900, 100), a T stem stops on the near face at (2950, 100) / (3050, 100), an X cuts all four faces, and the wall areas are 1 400 000, 1 490 000 and 2 360 000 mm² — computed by hand), `arch_room_area_net` (a 4 × 5 m room between 200 mm walls measures 20.00 m² in its label and its record, not the 21.84 m² its axes enclose), `arch_opening_cuts_wall` (both faces are interrupted across a 900 door and two jambs close it) and `arch_rooms_detect_foreign` (a plan of plain lines on a `WALLS` layer yields 20 and 15 m² at confidence 0.6, and nothing is written). 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 26 checks v1.5.1 was released on all still pass. The twenty-two fixed rows are new capability (`miss → pass`), and each one pins a number computed by hand, not read back from the code that drew it:
|
||||
|
||||
<details>
|
||||
<summary><b>The twenty-two checks 1.6 added</b></summary>
|
||||
|
||||
| Track | Check | Verified against |
|
||||
|---|---|---|
|
||||
| Understanding | `scale_check_detects_schematic` | the synthetic P&ID reads **schematic** against its layout; a uniform 2× copy of six tags reads **to scale** with factor 2 |
|
||||
| | `pipe_takeoff_rmst_exact` | all eight routable runs measure exactly the rectilinear MST of their tags |
|
||||
| | `cable_takeoff_roundup` | 9, 17, 19 and 10 m after the 20 % allowance; the heater's unstated power stays empty |
|
||||
| | `diff_detects_known_edits` | a move, a text change, an attribute change, an add and a delete, and nothing else |
|
||||
| | `topology_known_defects` | a 5 mm near miss, one interior crossing, and a clean T that is neither |
|
||||
| Architecture | `arch_junction_l_t_x` | an L corner mitres at (4100, −100) / (3900, 100), a T stem stops on the near face, an X cuts all four faces; wall areas 1 400 000, 1 490 000 and 2 360 000 mm² |
|
||||
| | `arch_room_area_net` | a 4 × 5 m room between 200 mm walls is labelled 20.00 m², not the 21.84 m² its axes enclose |
|
||||
| | `arch_opening_cuts_wall` | both faces are interrupted across a 900 door and two jambs close it |
|
||||
| | `arch_rooms_detect_foreign` | plain lines on a `WALLS` layer yield 20 and 15 m² at confidence 0.6, and nothing is written |
|
||||
| Mechanical and sheet | `mech_part_roundtrip` | a part read back out of its own `ACADMCP_MECH` XDATA equals the part drawn |
|
||||
| | `mech_section_hatch_area` | a 60 × ⌀40 sleeve with a ⌀20 bore cuts 1200 mm², 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 |
|
||||
| | `bom_balloon_link` | two identical bolts are one row of quantity 2, and its balloon carries the row's item number |
|
||||
| Environment | `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 |
|
||||
| | `settings_pdf_mediabox_a3` | the plotted PDF's own `/MediaBox` reads 420 × 297 |
|
||||
| P&ID | `pid_block_define_attdef_roundtrip`, `pid_tag_parse_fic`, `pid_graph_edge_count` | a block with ATTDEFs defined and read back, an ISA-5.1 tag parsed, a drawn P&ID's edge count |
|
||||
|
||||
</details>
|
||||
|
||||
### 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 four more — one the P&ID reader can fail on its own, one where the PDF file, not the setter, is the witness, one where a whole mechanical sheet has to come out clean, and one where a floor plan's rooms are read back off the drawing.
|
||||
An earlier matrix scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added in 1.5 because they *can* fail, and three of them did while being written. 1.6 adds six more:
|
||||
|
||||
- one the P&ID reader can fail on its own;
|
||||
- one where the PDF file, not the setter, is the witness;
|
||||
- one where a whole mechanical sheet has to come out clean;
|
||||
- one where a floor plan's rooms are read back off the drawing;
|
||||
- two where a foreign plant's P&ID and layout are read: the takeoffs against lengths computed in advance, and the one-call report against the defects planted in it.
|
||||
|
||||
| Task | Verified against |
|
||||
|---|---|
|
||||
| `tool_discovery` | six AutoCAD command names, each ranking #1 |
|
||||
| `token_budget` | 78,772 → 356 tokens, against a ceiling fixed in advance |
|
||||
| `token_budget` | 83,921 → 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 |
|
||||
| `arch_roundtrip` | a two-room plan — an entrance door, an interior door, two windows, a stair — drawn by `arch_plan_from_spec` and read back by `arch_rooms_detect`, which never sees the spec: each room within 0.1 % of its label and of the net floor computed by hand (4825 × 5750 and 3825 × 5750 mm), the door and window schedules listing D1, D2, W1, W2, `drawing_critique(focus=None)` at **zero** and a finalize score of at least **90**; a test removes one room label and watches the gate fail |
|
||||
| `page_setup_truth` | ANSI B, then ISO A3 landscape, applied to Layout1. Each is 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: a hub with a full section, a 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, an ISO 5457 frame and an 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 |
|
||||
| `arch_roundtrip` | a two-room plan (an entrance door, an interior door, two windows, a stair) drawn by `arch_plan_from_spec` and read back by `arch_rooms_detect`, which never sees the spec. Each room must be within 0.1 % of its label and of the net floor computed by hand (4825 × 5750 and 3825 × 5750 mm), and the schedules must list D1, D2, W1, W2. Gated like `mech_assembly`; a test removes one room label and watches the gate fail |
|
||||
| `takeoff_roundtrip` | the synthetic plant pair: a P&ID stretched 1.3× in one room and rearranged in the other, Russian supply / return words, a return drawn on the supply layer, a reducer, a segment drawn twice. The scale check must call it **schematic** (21.8 % of 55 pairs within ±10 %). `pipe_takeoff` must give every routable run's layout length equal to the rectilinear MST of its tags, sizes and services as drawn, and the run to a tag the layout lacks status C. `cable_takeoff` must give 9 / 17 / 19 / 10 m after the 20 % allowance and leave the heater's power empty. A test removes the wiring callouts and watches the cable half fail by name |
|
||||
| `understand_foreign` | the pair's layout (inches declared over millimetres, a stray line 5,000 km out, two plan copies) comes back as millimetres with an `INSUNITS` warning, the stray line's handle and two clusters. The P&ID's `P_product piping` / `P_cipsupplyline` / `P_cipreturnline` / `P_ijswater` / `E_power` classify as product / CIP supply / CIP return / ice water / electrical. A test makes the layout declare millimetres and watches the units gate fail |
|
||||
|
||||
### Headless performance
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-perf.svg" alt="Performance across four headless workloads" width="800">
|
||||
|
||||
Workloads call the same backend methods the MCP tools call, so server-side overhead is included. Numbers move with hardware; the report records the machine fingerprint. **Read the next section before quoting them** — this release is slower than v1.4.0 at creating entities, on purpose.
|
||||
The workloads call the same backend methods the MCP tools call, so server-side overhead is included. Numbers move with hardware; the report records the machine fingerprint. **Read the next section before quoting them.** This release is slower than v1.4.0 at creating entities.
|
||||
|
||||
## What this release is bad at
|
||||
|
||||
A page that only lists strengths is a page that has not been measured.
|
||||
|
||||
**Entity creation is slower than v1.4.0.** Median of three runs on one machine and interpreter: 2,000 lines **1.4× slower**, the 10,000-line roundtrip **2.3× slower**. Attributed — setting `EZDXF_CALL_TIMEOUT=0` returns creation to v1.4.0's numbers. The cost is the per-call `asyncio.wait_for` wrapped around every headless call so one hung call can no longer wedge a server whose document lock is a single `asyncio.Lock`. Deliberate trade, documented knob, on the 1.6 roadmap. The premium quality pass went the other way — **3.7× faster**.
|
||||
**Headless entity creation is slower than v1.4.0, and 1.6 did not win it back.** These are medians of five alternating runs on one machine, with one interpreter (CPython 3.11.15) and one ezdxf, v1.4.0's code against 1.6.0's ([`perf-v1.4.0-vs-v1.6.0.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/perf-v1.4.0-vs-v1.6.0.json)):
|
||||
|
||||
**Wave A shipped 13 tools of a planned 39.** Every cut is backed by a measurement. The REGION and 2D-boolean family went because `add_region()` produces a REGION with **zero ACIS bytes**, and the `greiner_hormann` substitute loses **28.2%** of the area on a square with one semicircular edge.
|
||||
| Workload | v1.4.0 | v1.6.0 | v1.6.0, `EZDXF_CALL_TIMEOUT=0` |
|
||||
|---|---:|---:|---:|
|
||||
| 2,000 lines, one call each | 244.5 ms | 306.1 ms (1.25×) | 284.9 ms (1.17×) |
|
||||
| 10,000 lines: build, DXF export, reopen | 1,847.9 ms | 2,553.5 ms (1.38×) | 2,216.4 ms (1.20×) |
|
||||
| Region query over 10,000 entities | 665.2 ms | 712.2 ms (1.07×) | 783.9 ms (1.18×) |
|
||||
| Premium quality pass | 150.6 ms | 65.3 ms (**2.3× faster**) | 69.9 ms |
|
||||
|
||||
**`system_run_command` / `system_run_lisp` are a guardrail, not a security boundary** — the rejection message says so in those words. A 36-verb denylist refuses the obvious cases, but AutoCAD accepts hundreds of commands and `DANGEROUS_COMMANDS_ENABLED=true` switches it off entirely.
|
||||
The per-call timeout stops one hung call from wedging a server whose document lock is a single `asyncio.Lock`. It explains only part of the slowdown: switched off, creation is still 1.17× and 1.20× slower. The rest is extra work per call that 1.6 has not removed. A cProfile of the event-loop thread counts 491,071 function calls for the 2,000 creates, against v1.4.0's 319,038. Earlier READMEs said that switching the timeout off returned creation to v1.4.0's numbers. Measured this way, it does not. The region-query row, which moves the wrong way, shows this machine's noise floor (about ±10 %). Roadmap criterion 7 is not met and moves to 1.7.
|
||||
|
||||
**Path validation is per-tool, and unscoped until you scope it.** With `ALLOWED_PATHS` empty — the default — the only positive bound is a ten-entry system-directory denylist that does not include `C:/Users`, `/home`, `/root` or `/var`. **Set `ALLOWED_PATHS`.**
|
||||
**Four mechanical feature tables ship empty.** DIN 471/472 retaining-ring grooves, DIN 509 undercuts, DIN 332 centre holes and ISO 3601-2 O-ring grooves have their drawing code and their provenance tests, but no rows. A groove diameter written from memory is a wrong workshop drawing, while a narrow table is merely narrow. So every size is refused by name until rows verified against the standard's own table are transcribed. That is a data edit; each module's docstring says how.
|
||||
|
||||
**Takeoffs are estimates, and they say so.**
|
||||
|
||||
- A layout length is the rectilinear minimum spanning tree of the tags a run connects, plus a stated vertical allowance, and it is labelled an estimate.
|
||||
- A P&ID length is used only when `drawing_scale_check` calls the P&ID to scale.
|
||||
- A power the P&ID does not state stays empty and becomes an open item, and no cable-section table is authored: a section comes only from your `section_rules`.
|
||||
- Where no callout names a panel, the nearest panel is inferred and flagged.
|
||||
- Detail copies of equipment are flagged, and excluded only when you ask.
|
||||
|
||||
The readers were tuned on the synthetic plant pair and on one real P&ID / layout pair that is not in this repository. Another drafting office's layer and text conventions may need vocabulary.
|
||||
|
||||
**No Plant 3D.** The roadmap's track D (read-only queries of a Plant 3D project) was dropped: the maintainer does not use Plant 3D, and a reader tested against nobody's real projects would be a guess. Roadmap criterion 5 is withdrawn, not met.
|
||||
|
||||
**No REGION or 2D booleans headlessly.** `add_region()` produces a REGION with **zero ACIS bytes**, and the `greiner_hormann` substitute loses **28.2%** of the area on a square with one semicircular edge.
|
||||
|
||||
**`system_run_command` / `system_run_lisp` are a guardrail, not a security boundary.** The rejection message says so in those words. A 36-verb denylist refuses the obvious cases, but AutoCAD accepts hundreds of commands, and `DANGEROUS_COMMANDS_ENABLED=true` switches the list off entirely.
|
||||
|
||||
**Path validation is per-tool, and unscoped until you scope it.** With `ALLOWED_PATHS` empty (the default), the only positive bound is a ten-entry system-directory denylist that does not include `C:/Users`, `/home`, `/root` or `/var`. **Set `ALLOWED_PATHS`.**
|
||||
|
||||
**Non-AutoCAD ProgIDs are unverified.** `CAD_PROGID` changes which COM application the backend attaches to; nothing beyond the connection has been tested against BricsCAD, ZWCAD or GstarCAD.
|
||||
|
||||
### Known limitations of the P&ID track
|
||||
|
||||
**The router is orthogonal and blind.** `pid_line_draw` picks `auto` from seven orthogonal candidates — straight, one bend, two bends through the midline, or a stub out of each port joined by three bends — by fewest bends then length (or takes `direct`, or the waypoints you pass), and *counts* the lines it crosses — `crossings` comes back in every result and `pid_from_spec` sums it — but it does not route around them. Obstacle-avoiding routing is out of 1.6; move the symbol or pass waypoints.
|
||||
**The router is orthogonal and blind.** `pid_line_draw` picks `auto` from seven orthogonal candidates, by fewest bends then length:
|
||||
|
||||
**A foreign drawing is read at the confidence it earns, and no higher.** A block this server did not place classifies by its `TAG`-style attribute or a keyword in its name at 0.6 with inferred ports, or at 0.3 when a line merely touches it — and a 0.3 guess can neither raise nor suppress a critique finding. On the live engine an INSERT turned off a right angle whose members ActiveX cannot measure reports an *approximate* box; `pid_graph` takes the tighter wall of that and the attribute-inclusive box on every side, but a line ending on such a body's true edge can still be reported `dangling` with the nearest port as the hint. Read `stats.confidence_min` first.
|
||||
- straight;
|
||||
- one bend;
|
||||
- two bends through the midline;
|
||||
- a stub out of each port joined by three bends.
|
||||
|
||||
**Same-file only, DXF headlessly.** Off-page connectors link within one drawing; links across files are not resolved. The headless engine reads DXF; a DWG P&ID is read through the live backend. No DEXPI/Proteus export and no ISA-5.2 binary-logic symbols in this release, recorded in the spec so nobody re-derives the cut.
|
||||
You can also take `direct`, or pass waypoints. It *counts* the lines it crosses (`crossings` comes back in every result, and `pid_from_spec` sums it), but it does not route around them. Obstacle-avoiding routing is not in 1.6; move the symbol or pass waypoints.
|
||||
|
||||
**A foreign drawing is read at the confidence it earns, and no higher.** A block this server did not place classifies at 0.6, with inferred ports, by its `TAG`-style attribute or a keyword in its name. It classifies at 0.3 when a line merely touches it, and a 0.3 guess can neither raise nor suppress a critique finding.
|
||||
|
||||
On the live engine, an INSERT turned off a right angle has members ActiveX cannot measure, so it reports an *approximate* box. `pid_graph` takes the tighter wall of that box and the attribute-inclusive box on every side. Even so, a line ending on such a body's true edge can still be reported `dangling`, with the nearest port as the hint. Read `stats.confidence_min` first.
|
||||
|
||||
**Same-file only, DXF headlessly.** Off-page connectors link within one drawing; links across files are not resolved. The headless engine reads DXF; a DWG P&ID is read through the live backend. There is no DEXPI/Proteus export and there are no ISA-5.2 binary-logic symbols in this release. Both cuts are recorded in the spec so nobody re-derives them.
|
||||
|
||||
### Known limitations of the settings track
|
||||
|
||||
**Layer states are ours.** `layer_state_save` writes a portable snapshot into an `ACADMCP_LAYERSTATES` XRECORD. It survives save/reopen on both engines and travels with the DWG/DXF; it is **not** an AutoCAD `LAYERSTATE` and does not appear in the Layer States Manager. `system_capabilities` reports `layer_states` as `mode: "xrecord"` for that reason.
|
||||
**Layer states are ours.** `layer_state_save` writes a portable snapshot into an `ACADMCP_LAYERSTATES` XRECORD. It survives save/reopen on both engines and travels with the DWG/DXF. It is **not** an AutoCAD `LAYERSTATE` and does not appear in the Layer States Manager, which is why `system_capabilities` reports `layer_states` as `mode: "xrecord"`.
|
||||
|
||||
**Six of the 22 environment tools need a live seat.** Preferences, launching, the operator prompts, and the five summary fields of document properties are refused headlessly with `preferences` / `dwgprops` / `live_application` / `interactive_prompt` — a refusal, not a stub. A `.dwt` template is the same case (`dwt_write`): a `.dwt` is a DWG container, so headlessly the template is saved as DXF. `mleaderstyle_create` is *not* such a case — ActiveX has no MLeaderStyle collection, but the `ACAD_MLEADERSTYLE` dictionary holds full `IAcadMLeaderStyle` objects, so both engines create leader styles.
|
||||
**Six of the 22 environment tools need a live seat.** Preferences, launching, the operator prompts and the five summary fields of document properties are refused headlessly with `preferences` / `dwgprops` / `live_application` / `interactive_prompt`. Each is a refusal, not a stub.
|
||||
|
||||
**A headless dimension does not re-render.** `dimstyle_modify` reports `dimensions_using_style` and `rerender_required: true` on the ezdxf engine; the DIMENSION keeps its rendered block until it is redrawn. AutoCAD regenerates on the next regen.
|
||||
A `.dwt` template is the same case (`dwt_write`): a `.dwt` is a DWG container, so headlessly the template is saved as DXF. `mleaderstyle_create` is *not* such a case. ActiveX has no MLeaderStyle collection, but the `ACAD_MLEADERSTYLE` dictionary holds full `IAcadMLeaderStyle` objects, so both engines create leader styles.
|
||||
|
||||
**A headless dimension does not re-render.** On the ezdxf engine, `dimstyle_modify` reports `dimensions_using_style` and `rerender_required: true`, and the DIMENSION keeps its rendered block until it is redrawn. AutoCAD regenerates on the next regen.
|
||||
|
||||
### Known limitations of the architecture track
|
||||
|
||||
**Nominal, not standard.** The furniture and sanitary blocks carry nominal catalogue sizes and say so. The stair tool reports the Blondel relation (2R + G within 600–650 mm) as a design rule, never as a building code. The `arch` layer colours are a repository convention: ISO 13567 fixes the names, not the colours. Labels speak English or Turkish; any other `lang` is refused with the list.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -258,7 +394,7 @@ Nothing loads a `.env` file — export these, or set them in your MCP client's `
|
||||
flowchart LR
|
||||
A[MCP host / AI agent] --> B[FastMCP 3 server]
|
||||
B --> C[Error · audit · timing · logging · capability-refusal middleware]
|
||||
C --> D[Typed contract · 19 modules · 27 capabilities]
|
||||
C --> D[Typed contract · 23 modules · 41 capability keys]
|
||||
D --> E[COM backend<br/>single-STA thread]
|
||||
D --> F[ezdxf backend<br/>asyncio.to_thread]
|
||||
E --> G[Live AutoCAD]
|
||||
@@ -302,11 +438,16 @@ uv run pytest
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
```
|
||||
|
||||
`uv.lock` pins the whole transitive graph, so this reproduces CI exactly. CI runs Linux (3.11 / 3.12), Windows (mocked-COM), plus package, Docker and MCP-registry jobs; the release gate runs the same three test lanes before anything reaches PyPI. Releases are tag-driven: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
||||
`uv.lock` pins the whole transitive graph, so this reproduces CI exactly. CI runs Linux (3.11 / 3.12) and Windows (mocked COM), plus package, Docker and MCP-registry jobs. The release gate runs the same three test lanes before anything reaches PyPI. Releases are tag-driven: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
||||
|
||||
## Roadmap (1.6)
|
||||
## Roadmap (1.7)
|
||||
|
||||
Give back the creation throughput the per-call timeout costs — arm it only for calls that can actually block. ISO 286 interference **shafts** r/s/t/u. HATCH boundary *geometry* in `entity_get` (1.5 measures a hatch's filled area but does not hand back its loops). REGION/3DSOLID area headlessly, which needs a modelling kernel ezdxf does not have. An *allowlist* of permitted AutoLISP heads, replacing the denylist — enumerating dangerous symbols does not terminate. `ezdxf.recover` as a fallback on `drawing_open`.
|
||||
- **Speed.** Win back the headless creation throughput that 1.6 lost against v1.4.0 (criterion 7): arm the per-call timeout only where a call can block, and remove the per-call work that came with it.
|
||||
- **IEC 60617 electrical schematics.** They reuse the P&ID track's symbol, port and graph machinery.
|
||||
- **Transcribed rows** for DIN 471/472, DIN 509, DIN 332 and ISO 3601-2, each verified against the standard's own table.
|
||||
- **HATCH boundary geometry in `entity_get`.** 1.5 and 1.6 measure a hatch's filled area but do not hand back its loops.
|
||||
- **`ezdxf.recover` as a fallback** on `drawing_open`.
|
||||
- **An allowlist of permitted AutoLISP heads**, replacing the denylist. Enumerating dangerous symbols does not terminate.
|
||||
|
||||
Features ship when their contracts and limitations are testable — not when they make a longer checklist.
|
||||
|
||||
|
||||
@@ -510,6 +510,17 @@ def _normalise_dimstyle_rounding(doc) -> None:
|
||||
continue
|
||||
|
||||
|
||||
def _arial_standard_style(doc) -> None:
|
||||
"""Standard in Arial, as AutoCAD 2026's acadiso.dwt ships it.
|
||||
|
||||
ezdxf's own default is txt.shx, which has no superscript two: an
|
||||
architectural area label 'm²' read 'm?' when AutoCAD opened the DXF, and
|
||||
rendered as a box headlessly. A new live drawing already starts in Arial,
|
||||
so the two engines now begin from the same Standard.
|
||||
"""
|
||||
doc.styles.get("Standard").dxf.font = "arial.ttf"
|
||||
|
||||
|
||||
def _apply_iso_dimstyle(doc) -> None:
|
||||
"""Store ISO defaults on the document's `Standard` dimension style."""
|
||||
try:
|
||||
@@ -2276,6 +2287,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
else:
|
||||
doc = ezdxf.new(dxfversion="R2010")
|
||||
_apply_iso_dimstyle(doc)
|
||||
_arial_standard_style(doc)
|
||||
# R32: registering a fresh Drawing is the release — the runaway keeps
|
||||
# the object it captured. The quarantined entry is evicted first so
|
||||
# it can never be re-activated. Clear before _reset_history_baseline,
|
||||
@@ -4861,21 +4873,34 @@ class EzdxfBackend(AutoCADBackend):
|
||||
line = msp.add_line((x0, yy), (x0 + layout.width, yy))
|
||||
self._apply_attrs(line, layer, None)
|
||||
children.append(line)
|
||||
for xx in x_positions:
|
||||
line = msp.add_line((xx, y0), (xx, y0 - layout.height))
|
||||
last = len(x_positions) - 1
|
||||
for index, xx in enumerate(x_positions):
|
||||
# a title row is merged, as AutoCAD's TABLE draws it: only the
|
||||
# outer edges cross it
|
||||
inner = 0 < index < last
|
||||
top = y0 - layout.row_height if layout.title_row and inner else y0
|
||||
line = msp.add_line((xx, top), (xx, y0 - layout.height))
|
||||
self._apply_attrs(line, layer, None)
|
||||
children.append(line)
|
||||
|
||||
padding = min(1.0, layout.row_height * 0.15)
|
||||
# the cell margin scales with the lettering: 1.0 at the default 2.5
|
||||
# text, 50 on a 1:50 schedule (a flat 1.0 there is 0.02 mm on the
|
||||
# sheet and the text sat on the cell line)
|
||||
padding = min(0.4 * layout.text_height, 0.15 * layout.row_height)
|
||||
for row_index, row in enumerate(layout.cells):
|
||||
for column_index, value in enumerate(row):
|
||||
if not value:
|
||||
continue
|
||||
span = (
|
||||
layout.width
|
||||
if layout.title_row and row_index == 0
|
||||
else layout.column_widths[column_index]
|
||||
)
|
||||
text_entity = msp.add_mtext(
|
||||
value,
|
||||
dxfattribs={
|
||||
"char_height": layout.text_height,
|
||||
"width": max(1.0, layout.column_widths[column_index] - 2 * padding),
|
||||
"width": max(1.0, span - 2 * padding),
|
||||
},
|
||||
)
|
||||
text_entity.dxf.insert = (
|
||||
|
||||
@@ -41,13 +41,14 @@ 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 v5
|
||||
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v5 --json
|
||||
python -m benchmarks.run_competitors --list --matrix v7
|
||||
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v7 --json
|
||||
python -m benchmarks.run_competitors --task table_mleader --task hatch_islands --json
|
||||
```
|
||||
|
||||
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).
|
||||
The release-machine ezdxf self-check is **21/21 (100.0)** on the v7 matrix
|
||||
(`--matrix v6` … `--matrix v2` reproduce the earlier sets); the published
|
||||
report is [`results/published/autocad-mcp-pro.json`](results/published/autocad-mcp-pro.json).
|
||||
Repository stars and raw tool counts do not contribute to the score. Adapter
|
||||
registration lives in `competitors.yaml`.
|
||||
|
||||
@@ -127,6 +128,17 @@ result for it.
|
||||
|
||||
The competitor reports carry no result for it.
|
||||
|
||||
### Matrix v7 (v1.6) — understanding foreign drawings
|
||||
|
||||
`tasks_v7.py` keeps the v6 nineteen unchanged and adds track H's evidence, both on the synthetic plant pair of `tests/fixtures/plant_pair.py` (invented names; its truth is computed from the positions it placed, never typed):
|
||||
|
||||
| Task | Category | Verified against |
|
||||
|---|---|---|
|
||||
| `takeoff_roundtrip` | plant | `scale_check(pid, layout)` must say **schematic** (room 1 is a 1.3× stretch, room 2 is rearranged: 21.8 % of the 55 tag pairs within ±10 % of the median ratio); `pipe_rows(..., length_source="auto")` must report all nine runs with the truth's status, each routable run's layout length equal to the rectilinear MST of its tags to a micron (PR1 5500, PR2 13200, CS1 20700 mm, …), its size and service as drawn — the CIP return drawn on the supply layer included — and both sizes across the reducer; `cable_rows` must give M11 / T102 / M12 / T202 their panels, 6900 / 14100 / 15800 / 8000 mm and 9 / 17 / 19 / 10 m after the 20 % allowance, the heater's power empty. `tests/test_benchmark_v7.py` deletes the wiring callouts and the gate names all four loads |
|
||||
| `understand_foreign` | understand | `describe(layout)` must infer millimetres and warn about `INSUNITS` (the file says inches), name the stray line's handle in its extents and report two clusters (the plan is drawn twice, 100 m apart); `describe(pid)` must classify `P_product piping`, `P_cipsupplyline`, `P_cipreturnline`, `P_ijswater` and `E_power` as product, CIP supply, CIP return, ice water and electrical. `tests/test_benchmark_v7.py` makes the layout declare millimetres and the units gate fails alone |
|
||||
|
||||
The competitor reports carry no result for them.
|
||||
|
||||
## Live competitor lane (v1.4)
|
||||
|
||||
Two competitor adapters now execute the exact same task matrix, black-box over
|
||||
@@ -134,13 +146,13 @@ MCP stdio, against commits pinned in `competitors.yaml`:
|
||||
|
||||
| Server | Matrix | Pinned | Score | Pass | Coverage |
|
||||
|---|---|---|---:|---:|---:|
|
||||
| autocad-mcp-pro (reference) | v3 (15) | working tree | 100.0 | 15/15 | 100% |
|
||||
| autocad-mcp-pro (reference) | v7 (21) | 1.6.0 tree | 100.0 | 21/21 | 100% |
|
||||
| beiming183-cloud/AutoCAD-MCP | v2 (10) | `11f7c47e` | 50.0 | 5/10 | 50% |
|
||||
| puran-water/autocad-mcp | v2 (10) | `95476a33` | 45.0 | 4/10 | 50% |
|
||||
|
||||
The competitor rows are the v1.4 runs, unchanged. Read the matrix column
|
||||
before the score column: the denominators differ because the five v3 tasks
|
||||
were added after those runs and have not been put to them.
|
||||
before the score column: the denominators differ because the eleven tasks
|
||||
added since v2 came after those runs and have not been put to them.
|
||||
|
||||
Method and boundaries:
|
||||
|
||||
@@ -194,38 +206,79 @@ python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.js
|
||||
python -m benchmarks.render_perf_chart
|
||||
```
|
||||
|
||||
### What the call-timeout guard costs
|
||||
### v1.4.0 against v1.6.0, and what the call-timeout guard costs
|
||||
|
||||
The published v1.4 report was recorded on CPython 3.14 and the later ones on
|
||||
3.11.15, so comparing those two files directly shows an apparent 2–6× speedup
|
||||
that is almost entirely the interpreter. The comparison that means something is
|
||||
the guard against no guard, on one interpreter, back to back. Three runs of
|
||||
each configuration, median taken by hand — `perf_suite` executes each workload
|
||||
once per invocation and cannot produce a median itself:
|
||||
3.11.15, so comparing those files directly shows an apparent speedup that is
|
||||
almost entirely the interpreter. The comparison that means something runs one
|
||||
interpreter and one ezdxf over each tree's own code: 1.6.0's `perf_suite.py`
|
||||
(v1.4.0 had none) against a v1.4.0 worktree, against 1.6.0, and against 1.6.0
|
||||
with the guard off — five rounds, the three configurations alternating within
|
||||
a round. Medians, from
|
||||
[`results/published/perf-v1.4.0-vs-v1.6.0.json`](results/published/perf-v1.4.0-vs-v1.6.0.json),
|
||||
which keeps every run:
|
||||
|
||||
| Workload | `EZDXF_CALL_TIMEOUT=0` | default (120 s) | Cost |
|
||||
| Workload | v1.4.0 | v1.6.0 | v1.6.0, `EZDXF_CALL_TIMEOUT=0` |
|
||||
|---|---:|---:|---:|
|
||||
| `create_lines_2k` | 243.7 ms | 294.3 ms | **+20.8%** |
|
||||
| `roundtrip_10k` | 1,709.6 ms | 2,049.2 ms | **+19.9%** |
|
||||
| `create_lines_2k` | 244.5 ms | 306.1 ms (1.25×) | 284.9 ms (1.17×) |
|
||||
| `roundtrip_10k` | 1,847.9 ms | 2,553.5 ms (1.38×) | 2,216.4 ms (1.20×) |
|
||||
| `region_query_10k` | 665.2 ms | 712.2 ms (1.07×) | 783.9 ms (1.18×) |
|
||||
| `premium_pass` | 150.6 ms | 65.3 ms (0.43×) | 69.9 ms (0.46×) |
|
||||
|
||||
v1.5.0 paid 28.6% on the first workload; v1.5.1 rebuilt the wrapper around a
|
||||
single `asyncio.timeout_at` instead of two `asyncio.wait_for`s — `wait_for`
|
||||
wraps its awaitable in a Task, so the old shape created two extra Tasks and two
|
||||
timer handles per call — which recovered about a third of the overhead. The
|
||||
rest is the Task the abandon-on-timeout design genuinely needs. The guard is
|
||||
what stops one hung ezdxf call wedging a server whose document lock is a single
|
||||
`asyncio.Lock`, so it stays; the knob is documented rather than hidden.
|
||||
The guard explains part of the creation cost, not all of it. v1.5.1 rebuilt
|
||||
the wrapper around one `asyncio.timeout_at` instead of two `asyncio.wait_for`s
|
||||
(`wait_for` wraps its awaitable in a Task, so the old shape created two extra
|
||||
Tasks and two timer handles per call), which recovered about a third of its
|
||||
overhead; the rest is the Task the abandon-on-timeout design needs. With the
|
||||
guard off, creation is still 1.17× and 1.20× v1.4.0's time. A cProfile of the
|
||||
event-loop thread over the 2,000 creates counts **491,071** function calls on
|
||||
1.6.0 against **319,038** on v1.4.0: per-call work added since 1.4 that 1.6
|
||||
has not removed. The region-query row, whose guard-off median is the slower
|
||||
one, is this machine's noise floor (about ±10 %).
|
||||
|
||||
These figures are hand-collected and have **no published artifact** —
|
||||
`perf-ezdxf.json` records one default-configuration run only. Reproduce:
|
||||
Roadmap criterion 7 — headless creation throughput back to v1.4.0's numbers —
|
||||
is therefore **not met** and moves to 1.7. Earlier versions of this page said
|
||||
that `EZDXF_CALL_TIMEOUT=0` returned creation to v1.4.0's numbers; measured
|
||||
this way, it does not. The guard stays: it is what stops one hung ezdxf call
|
||||
wedging a server whose document lock is a single `asyncio.Lock`.
|
||||
|
||||
`perf-ezdxf.json`, the chart's input, is the median of the five
|
||||
default-configuration runs, with every run kept in `runs_ms`.
|
||||
|
||||
Reproduce (the v1.4.0 tree needs 1.6.0's `benchmarks/perf_suite.py` copied in):
|
||||
|
||||
```bash
|
||||
for i in 1 2 3; do
|
||||
python -m benchmarks.perf_suite --out /tmp/on-$i.json
|
||||
EZDXF_CALL_TIMEOUT=0 python -m benchmarks.perf_suite --out /tmp/off-$i.json
|
||||
git worktree add ../v140 v1.4.0 && cp benchmarks/perf_suite.py ../v140/benchmarks/
|
||||
for i in 1 2 3 4 5; do
|
||||
python -m benchmarks.perf_suite --json > main-$i.json
|
||||
EZDXF_CALL_TIMEOUT=0 python -m benchmarks.perf_suite --json > off-$i.json
|
||||
(cd ../v140 && python -m benchmarks.perf_suite --json) > v140-$i.json
|
||||
done
|
||||
```
|
||||
|
||||
The profile, run from each tree's root:
|
||||
|
||||
```python
|
||||
import asyncio, cProfile, pstats
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
|
||||
async def work():
|
||||
b = EzdxfBackend()
|
||||
await b.connect()
|
||||
await b.drawing_new()
|
||||
for i in range(2000):
|
||||
await b.entity_create_line(0, i, 100, i)
|
||||
await b.disconnect()
|
||||
|
||||
|
||||
pr = cProfile.Profile()
|
||||
pr.enable()
|
||||
asyncio.run(work())
|
||||
pr.disable()
|
||||
print(pstats.Stats(pr).total_calls)
|
||||
```
|
||||
|
||||
## Correctness A/B suite
|
||||
|
||||
`correctness_suite.py` is a set of deterministic, headless (ezdxf-backend) checks
|
||||
@@ -242,17 +295,17 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
|
||||
python benchmarks/compare_versions.py --json results.json
|
||||
```
|
||||
|
||||
### Result — this branch (1.6.0-dev) vs v1.5.1 (release gate)
|
||||
### Result — v1.6.0 vs v1.5.1 (release gate)
|
||||
|
||||
43 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).
|
||||
48 checks, ezdxf backend, one subprocess per check. Machine-readable report:
|
||||
[`results/published/ab-v1.5.1-vs-v1.6.0.json`](results/published/ab-v1.5.1-vs-v1.6.0.json).
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---------|----------------|-----------|-------|-----------|
|
||||
| **v1.5.1** (baseline) | 26 / 43 | 60.5 % | — | — |
|
||||
| **v1.6.0-dev** (this branch) | 43 / 43 | 100 % | 17 | **0** |
|
||||
| **v1.5.1** (baseline) | 26 / 48 | 54.2 % | — | — |
|
||||
| **v1.6.0** (this release) | 48 / 48 | 100 % | 22 | **0** |
|
||||
|
||||
The seventeen are all `miss → pass` — v1.5.1 has none of the methods. Four are track F: `arch_junction_l_t_x` (L, T and X junction outlines against hand-computed corners and wall areas), `arch_room_area_net` (a 4 × 5 m room between 200 mm walls is labelled 20.00 m², not its 21.84 m² axis area), `arch_opening_cuts_wall` (both faces interrupted across a door, two jambs) and `arch_rooms_detect_foreign` (plain lines on a WALLS layer read as 20 and 15 m² at confidence 0.6, nothing written). Seven
|
||||
The twenty-two are all `miss → pass` — v1.5.1 has none of the methods. Five are track H: `scale_check_detects_schematic` (the synthetic P&ID is schematic against its layout; a uniform 2× copy is to scale with factor 2), `pipe_takeoff_rmst_exact` (eight routable runs, each exactly the rectilinear MST of its tags), `cable_takeoff_roundup` (9 / 17 / 19 / 10 m, the unstated power left empty), `diff_detects_known_edits` (five known edits and nothing else) and `topology_known_defects` (one near miss, one crossing, a clean T). Four are track F: `arch_junction_l_t_x` (L, T and X junction outlines against hand-computed corners and wall areas), `arch_room_area_net` (a 4 × 5 m room between 200 mm walls is labelled 20.00 m², not its 21.84 m² axis area), `arch_opening_cuts_wall` (both faces interrupted across a door, two jambs) and `arch_rooms_detect_foreign` (plain lines on a WALLS layer read as 20 and 15 m² at confidence 0.6, nothing written). 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,
|
||||
@@ -275,7 +328,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 four tracks added capability without moving a number it had already earned.
|
||||
the five tracks added capability without moving a number it had already earned.
|
||||
|
||||
### Result — v1.5.1 vs v1.5.0
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Reference adapter for this repository, covering the v6 task matrix.
|
||||
"""Reference adapter for this repository, covering the v7 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_v6 import TaskSpec
|
||||
from benchmarks.tasks_v7 import TaskSpec
|
||||
from engineering.delivery import deliver_drawing
|
||||
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
|
||||
from engineering.refiner import refine_drawing
|
||||
@@ -779,6 +779,149 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
|
||||
[str(sheet)],
|
||||
)
|
||||
|
||||
async def _task_takeoff_roundtrip(self):
|
||||
"""Pipe and cable quantities off the synthetic plant pair, against its truth.
|
||||
|
||||
Track H's evidence (spec §15). The pair is written fresh into the
|
||||
artifact folder by ``tests/fixtures/plant_pair.py``, whose truth is
|
||||
computed from the positions it placed. Then only the two DXFs are read:
|
||||
|
||||
* ``scale_check(pid, layout)`` must say ``schematic`` - room 1 is the
|
||||
layout stretched 1.3x, room 2 is rearranged, and 21.8 % of the 55 tag
|
||||
pairs lie within ±10 % of the median ratio;
|
||||
* ``pipe_rows(..., length_source="auto")`` must report every run of the
|
||||
P&ID (the ice-water utility included, because ``services`` names it)
|
||||
with the truth's status; a routable run's layout length must equal the
|
||||
rectilinear MST of its tags exactly, its sizes (both across the
|
||||
reducer) and its service must be the drawn ones (the CIP return drawn
|
||||
on the supply layer included); the run ending on T105, which the
|
||||
layout lacks, is status C with no layout length;
|
||||
* ``cable_rows(pid, layout)`` must give every load its panel from the
|
||||
layout's wiring callouts, its stated power (the heater's stays empty),
|
||||
the Manhattan length to the panel and the metres rounded up after the
|
||||
20 % allowance.
|
||||
|
||||
Any one slipping fails the task, and the metrics name what slipped.
|
||||
"""
|
||||
from engineering.understand.network import build_network
|
||||
from engineering.understand.scale import scale_check
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from engineering.understand.takeoff import cable_rows, pipe_rows
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
truth = build_plant_pair(self.artifact_dir / "plant_pair")
|
||||
pid = read_snapshot(truth["pid"])
|
||||
layout = read_snapshot(truth["layout"])
|
||||
scale = scale_check(pid, layout)
|
||||
services = tuple(sorted({run["service"] for run in truth["pipe_runs"]}))
|
||||
pipe = pipe_rows(
|
||||
build_network(pid), pid, layout, scale=scale, services=services, length_source="auto"
|
||||
)
|
||||
found = {(run["service"], tuple(sorted(run["tags"]))): run for run in pipe["runs"]}
|
||||
pipe_mismatch = []
|
||||
for run in truth["pipe_runs"]:
|
||||
got = found.get((run["service"], tuple(run["tags"])))
|
||||
if got is None:
|
||||
pipe_mismatch.append({"run": run["id"], "reason": "no run with these tags"})
|
||||
continue
|
||||
reasons = []
|
||||
if got["status"] != run["status"]:
|
||||
reasons.append(f"status {got['status']} != {run['status']}")
|
||||
want, have = run["layout_length_mm"], got.get("layout_m")
|
||||
if want is None and have is not None:
|
||||
reasons.append(f"a layout length ({have} m) for an unroutable run")
|
||||
elif want is not None and (have is None or abs(float(have) * 1000.0 - want) > 1e-6):
|
||||
reasons.append(f"layout length {have} m != {want} mm")
|
||||
if set(got["diameters"]) != set(run["diameters"]):
|
||||
reasons.append(f"sizes {sorted(got['diameters'])} != {sorted(run['diameters'])}")
|
||||
if reasons:
|
||||
pipe_mismatch.append({"run": run["id"], "reason": "; ".join(reasons)})
|
||||
|
||||
# panel_rule="stated": the gate reads what the drawing says; a nearest-panel
|
||||
# guess is no evidence
|
||||
cable = {row["tag"]: row for row in cable_rows(pid, layout, panel_rule="stated")["rows"]}
|
||||
cable_mismatch = []
|
||||
for want in truth["cable_rows"]:
|
||||
got = cable.get(want["tag"])
|
||||
if got is None:
|
||||
cable_mismatch.append({"tag": want["tag"], "wrong": ["missing"]})
|
||||
continue
|
||||
wrong = [key for key in ("panel", "kw") if got.get(key) != want[key]]
|
||||
if got.get("cable_m") != want["roundup_m"]:
|
||||
wrong.append("cable_m")
|
||||
length = got.get("length_m")
|
||||
if length is None or abs(float(length) * 1000.0 - want["length_mm"]) > 1e-6:
|
||||
wrong.append("length_m")
|
||||
if wrong:
|
||||
cable_mismatch.append({"tag": want["tag"], "wrong": wrong})
|
||||
|
||||
passed = scale["verdict"] == truth["scale_verdict"] and not pipe_mismatch
|
||||
passed = passed and not cable_mismatch
|
||||
return (
|
||||
passed,
|
||||
{
|
||||
"scale_verdict": scale["verdict"],
|
||||
"within_10pct": scale.get("within_10pct"),
|
||||
"length_source": pipe.get("length_source"),
|
||||
"runs_checked": len(truth["pipe_runs"]),
|
||||
"pipe_mismatch": pipe_mismatch,
|
||||
"loads_checked": len(truth["cable_rows"]),
|
||||
"cable_mismatch": cable_mismatch,
|
||||
},
|
||||
[truth["pid"], truth["layout"]],
|
||||
)
|
||||
|
||||
async def _task_understand_foreign(self):
|
||||
"""One call on a foreign layout reports what is wrong with it, by evidence.
|
||||
|
||||
Track H's evidence (spec §5, §15), on the synthetic plant pair: the
|
||||
layout declares inches (``$INSUNITS = 1``) over millimetre geometry, a
|
||||
stray LINE 5,000 km away stretches its declared extents, and the plan is
|
||||
drawn twice, 100 m apart. ``describe`` of the layout must infer
|
||||
millimetres, report that they disagree with the declared unit and warn
|
||||
about ``INSUNITS``, name the stray entity's handle in its extents, and
|
||||
report exactly two clusters; ``describe`` of the P&ID must classify its
|
||||
four service layers and the electrical layer as the generator names them
|
||||
(English and Dutch names). A warning or an extents report is searched as
|
||||
JSON, so the gate holds whatever shape the evidence takes.
|
||||
"""
|
||||
import json
|
||||
|
||||
from engineering.understand.describe import describe
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
truth = build_plant_pair(self.artifact_dir / "plant_pair")
|
||||
layout = describe(read_snapshot(truth["layout"]))
|
||||
pid = describe(read_snapshot(truth["pid"]))
|
||||
units = layout["units"]
|
||||
units_flagged = (
|
||||
str(units.get("inferred")) == truth["layout_true_unit"]
|
||||
and units.get("agree") is False
|
||||
and "INSUNITS" in json.dumps(layout["warnings"], ensure_ascii=False)
|
||||
)
|
||||
outlier_found = f'"{truth["outlier_handle"]}"' in json.dumps(layout["extents"])
|
||||
copies_separated = len(layout["clusters"]) == truth["cluster_count"]
|
||||
read = {row["name"]: row["service"] for row in pid["layers"]}
|
||||
misread = {
|
||||
name: read.get(name)
|
||||
for name, service in truth["layer_services"].items()
|
||||
if read.get(name) != service
|
||||
}
|
||||
passed = units_flagged and outlier_found and copies_separated and not misread
|
||||
return (
|
||||
passed,
|
||||
{
|
||||
"units": units,
|
||||
"units_flagged": units_flagged,
|
||||
"outlier_found": outlier_found,
|
||||
"clusters": len(layout["clusters"]),
|
||||
"copies_separated": copies_separated,
|
||||
"misread_layers": misread,
|
||||
},
|
||||
[truth["layout"], truth["pid"]],
|
||||
)
|
||||
|
||||
async def _task_auditable_delivery(self):
|
||||
await ensure_engineering_layers(self.backend)
|
||||
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
||||
|
||||
@@ -818,6 +818,216 @@ async def arch_rooms_detect_foreign():
|
||||
)
|
||||
|
||||
|
||||
# ── Understanding foreign drawings (track H) ───────────────────────────────
|
||||
|
||||
|
||||
def _plant_pair() -> dict:
|
||||
"""The synthetic plant pair, written to a fresh temporary folder."""
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
return build_plant_pair(tempfile.mkdtemp(prefix="acadmcp_plant_"))
|
||||
|
||||
|
||||
def _quoted(handle: str) -> str:
|
||||
"""A handle as it appears in a JSON dump - quoted, so '2A' never matches '12A3'."""
|
||||
return f'"{handle}"'
|
||||
|
||||
|
||||
async def scale_check_detects_schematic():
|
||||
"""The synthetic P&ID is schematic against its layout; a uniform 2x copy is not.
|
||||
|
||||
The pair: room 1 of the P&ID is the layout stretched 1.3x, room 2 is
|
||||
rearranged, and of the 55 pairs of the 11 shared tags 21.8 % lie within
|
||||
±10 % of the median ratio - at most 50 % is ``schematic`` (spec §6).
|
||||
The control: six tags at (0, 0), (4000, 0), (8000, 0), (0, 3000),
|
||||
(4000, 3000), (8000, 6000) and the same tags at twice those coordinates -
|
||||
the nearest pair is 3000 mm apart, every ratio is exactly 2.0, so the
|
||||
doubled drawing is ``to_scale`` against the first with factor 2.
|
||||
"""
|
||||
import ezdxf
|
||||
|
||||
from engineering.understand.scale import scale_check
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
|
||||
truth = _plant_pair()
|
||||
pair = scale_check(read_snapshot(truth["pid"]), read_snapshot(truth["layout"]))
|
||||
folder = tempfile.mkdtemp(prefix="acadmcp_scale_")
|
||||
positions = [(0, 0), (4000, 0), (8000, 0), (0, 3000), (4000, 3000), (8000, 6000)]
|
||||
paths = []
|
||||
for factor in (1.0, 2.0):
|
||||
doc = ezdxf.new("R2018")
|
||||
msp = doc.modelspace()
|
||||
for number, (x, y) in enumerate(positions, start=1):
|
||||
msp.add_circle((x * factor, y * factor), 500.0)
|
||||
msp.add_text(f"T{number}", dxfattribs={"insert": (x * factor, y * factor)})
|
||||
path = os.path.join(folder, f"x{factor:g}.dxf")
|
||||
doc.saveas(path)
|
||||
paths.append(path)
|
||||
control = scale_check(read_snapshot(paths[1]), read_snapshot(paths[0]))
|
||||
return (
|
||||
pair["verdict"] == "schematic"
|
||||
and control["verdict"] == "to_scale"
|
||||
and abs(float(control["factor"]) - 2.0) < 1e-9
|
||||
)
|
||||
|
||||
|
||||
async def pipe_takeoff_rmst_exact():
|
||||
"""Every routable pipe run's layout length is the rectilinear MST of its tags.
|
||||
|
||||
Measured on the synthetic pair with ``length_source="layout"``. The truth
|
||||
is the generator's own Manhattan / Prim arithmetic over the tag positions it
|
||||
placed, hand-checked in tests/test_plant_pair.py - PR1 5500 (5000 + 500),
|
||||
PR2 13200 (the three distances 6000 / 7200 / 13200, spanning tree 6000 +
|
||||
7200), CS1 20700 (20500 + 200). All eight routable runs must agree to a
|
||||
micron; the run ending on T105, which the layout lacks, is not routable.
|
||||
"""
|
||||
from engineering.understand.network import build_network
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from engineering.understand.takeoff import pipe_rows
|
||||
|
||||
truth = _plant_pair()
|
||||
pid, layout = read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
|
||||
services = tuple(sorted({run["service"] for run in truth["pipe_runs"]}))
|
||||
result = pipe_rows(build_network(pid), pid, layout, services=services, length_source="layout")
|
||||
found = {(r["service"], tuple(sorted(r["tags"]))): r for r in result["runs"]}
|
||||
routable = [run for run in truth["pipe_runs"] if run["layout_length_mm"] is not None]
|
||||
for run in routable:
|
||||
got = found.get((run["service"], tuple(run["tags"])))
|
||||
if got is None or got.get("layout_m") is None:
|
||||
return False
|
||||
if abs(float(got["layout_m"]) * 1000.0 - run["layout_length_mm"]) > 1e-6:
|
||||
return False
|
||||
return len(routable) == 8
|
||||
|
||||
|
||||
async def cable_takeoff_roundup():
|
||||
"""Cable rows: the panel from the callout, Manhattan length, metres rounded up.
|
||||
|
||||
The truth rows of the synthetic pair, by hand: M11 (5500, 8200) to CP-1
|
||||
(1200, 10800) is 6900 mm, 6.9 m x 1.2 = 8.28 -> 9; T102 (8000, 3500) to CP-1
|
||||
is 14100 mm -> 16.92 -> 17; M12 (26300, 8400) to CP-2 (35000, 1300) is
|
||||
15800 mm -> 18.96 -> 19; T202 (28700, 3000) to CP-2 is 8000 mm -> 9.6 -> 10.
|
||||
The heater on T202 states no power, and its row must keep an empty one.
|
||||
"""
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from engineering.understand.takeoff import cable_rows
|
||||
|
||||
truth = _plant_pair()
|
||||
rows = {
|
||||
row["tag"]: row
|
||||
for row in cable_rows(
|
||||
read_snapshot(truth["pid"]), read_snapshot(truth["layout"]), panel_rule="stated"
|
||||
)["rows"]
|
||||
}
|
||||
expected = {"M11": ("CP-1", 9), "T102": ("CP-1", 17), "M12": ("CP-2", 19), "T202": ("CP-2", 10)}
|
||||
for tag, (panel, metres) in expected.items():
|
||||
row = rows.get(tag)
|
||||
if row is None or row.get("panel") != panel or row.get("cable_m") != metres:
|
||||
return False
|
||||
return rows["T202"].get("kw") is None and rows["M11"].get("kw") == 5.5
|
||||
|
||||
|
||||
async def diff_detects_known_edits():
|
||||
"""Two revisions differing by exactly five edits - and the diff says so.
|
||||
|
||||
A move (a LINE shifted 10 mm), a text change (PUMP A -> PUMP B), an
|
||||
attribute change (TAG T101 -> T102), an add (a new LINE) and a delete (a
|
||||
LINE removed); a CIRCLE stays untouched. The edits are made on a copy read
|
||||
back from the first file, so every surviving entity keeps its handle. The
|
||||
diff must list the added line as added, the removed line as removed, the
|
||||
three edited entities as changed - and nothing else, the circle included.
|
||||
"""
|
||||
import json
|
||||
|
||||
import ezdxf
|
||||
|
||||
from engineering.understand.diff import diff_snapshots
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
|
||||
folder = tempfile.mkdtemp(prefix="acadmcp_diff_")
|
||||
old_path, new_path = os.path.join(folder, "rev_a.dxf"), os.path.join(folder, "rev_b.dxf")
|
||||
doc = ezdxf.new("R2018")
|
||||
msp = doc.modelspace()
|
||||
moved = msp.add_line((0, 0), (100, 0), dxfattribs={"layer": "PIPE"})
|
||||
text = msp.add_text("PUMP A", dxfattribs={"insert": (0, 50), "height": 2.5})
|
||||
block = doc.blocks.new("TAGGED")
|
||||
block.add_circle((0, 0), 10)
|
||||
block.add_attdef("TAG", (0, 0), dxfattribs={"height": 2.5})
|
||||
insert = msp.add_blockref("TAGGED", (200, 0))
|
||||
insert.add_auto_attribs({"TAG": "T101"})
|
||||
gone = msp.add_line((0, 100), (100, 100), dxfattribs={"layer": "PIPE"})
|
||||
kept = msp.add_circle((300, 300), 20)
|
||||
doc.saveas(old_path)
|
||||
|
||||
revision = ezdxf.readfile(old_path)
|
||||
revision.entitydb[moved.dxf.handle].translate(10, 0, 0)
|
||||
revision.entitydb[text.dxf.handle].dxf.text = "PUMP B"
|
||||
revision.entitydb[insert.dxf.handle].get_attrib("TAG").dxf.text = "T102"
|
||||
revision.modelspace().delete_entity(revision.entitydb[gone.dxf.handle])
|
||||
added = revision.modelspace().add_line((500, 500), (600, 500), dxfattribs={"layer": "PIPE"})
|
||||
revision.saveas(new_path)
|
||||
|
||||
diff = diff_snapshots(read_snapshot(old_path), read_snapshot(new_path))
|
||||
changed = json.dumps(diff["changed"])
|
||||
dumped = changed + json.dumps(diff["added"]) + json.dumps(diff["removed"])
|
||||
return (
|
||||
len(diff["added"]) == 1
|
||||
and len(diff["removed"]) == 1
|
||||
and len(diff["changed"]) == 3
|
||||
and _quoted(added.dxf.handle) in json.dumps(diff["added"])
|
||||
and _quoted(gone.dxf.handle) in json.dumps(diff["removed"])
|
||||
and all(_quoted(e.dxf.handle) in changed for e in (moved, text, insert))
|
||||
and _quoted(kept.dxf.handle) not in dumped
|
||||
)
|
||||
|
||||
|
||||
async def topology_known_defects():
|
||||
"""Dangling ends, a near miss and a crossing, placed on purpose - and nothing else.
|
||||
|
||||
On layer PIPE: a closed 1000 x 1000 square (no free end, no defect); a LINE
|
||||
from (500, -500) that stops at (500, -5), 5 mm short of the square's bottom
|
||||
edge (a near miss at gap 10, and a dangling end); two LINEs crossing at
|
||||
(2500, 500), away from all four of their ends (one interior crossing); and a
|
||||
clean T - a LINE ending on the middle of another at (4500, 0), which is a
|
||||
junction, not a crossing and not a near miss.
|
||||
"""
|
||||
import json
|
||||
|
||||
import ezdxf
|
||||
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from engineering.understand.topology import topology_findings
|
||||
|
||||
doc = ezdxf.new("R2018")
|
||||
msp = doc.modelspace()
|
||||
pipe = {"layer": "PIPE"}
|
||||
ring = msp.add_lwpolyline(
|
||||
[(0, 0), (1000, 0), (1000, 1000), (0, 1000)], close=True, dxfattribs=pipe
|
||||
)
|
||||
short = msp.add_line((500, -500), (500, -5), dxfattribs=pipe)
|
||||
cross_a = msp.add_line((2000, 0), (3000, 1000), dxfattribs=pipe)
|
||||
cross_b = msp.add_line((2000, 1000), (3000, 0), dxfattribs=pipe)
|
||||
bar = msp.add_line((4000, 0), (5000, 0), dxfattribs=pipe)
|
||||
stem = msp.add_line((4500, 0), (4500, 500), dxfattribs=pipe)
|
||||
path = os.path.join(tempfile.mkdtemp(prefix="acadmcp_topo_"), "topology.dxf")
|
||||
doc.saveas(path)
|
||||
|
||||
found = topology_findings(read_snapshot(path), layers=["PIPE"], gap=10.0)
|
||||
near = json.dumps(found["near_miss"])
|
||||
crossing = json.dumps(found["crossing"])
|
||||
dangling = json.dumps(found["dangling"])
|
||||
return (
|
||||
_quoted(short.dxf.handle) in near
|
||||
and _quoted(short.dxf.handle) in dangling
|
||||
and _quoted(ring.dxf.handle) not in dangling
|
||||
and _quoted(ring.dxf.handle) not in crossing
|
||||
and len(found["crossing"]) == 1
|
||||
and _quoted(cross_a.dxf.handle) in crossing
|
||||
and _quoted(cross_b.dxf.handle) in crossing
|
||||
and all(_quoted(e.dxf.handle) not in crossing + near for e in (bar, stem))
|
||||
)
|
||||
|
||||
|
||||
CHECKS = {
|
||||
"core_line_length": (core_line_length, "Core"),
|
||||
"core_circle_radius": (core_circle_radius, "Core"),
|
||||
@@ -862,6 +1072,11 @@ CHECKS = {
|
||||
"arch_room_area_net": (arch_room_area_net, "Architecture"),
|
||||
"arch_opening_cuts_wall": (arch_opening_cuts_wall, "Architecture"),
|
||||
"arch_rooms_detect_foreign": (arch_rooms_detect_foreign, "Architecture"),
|
||||
"scale_check_detects_schematic": (scale_check_detects_schematic, "Understanding"),
|
||||
"pipe_takeoff_rmst_exact": (pipe_takeoff_rmst_exact, "Understanding"),
|
||||
"cable_takeoff_roundup": (cable_takeoff_roundup, "Understanding"),
|
||||
"diff_detects_known_edits": (diff_detects_known_edits, "Understanding"),
|
||||
"topology_known_defects": (topology_known_defects, "Understanding"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
|
||||
ax.set_title(
|
||||
"Public AutoCAD MCP leaderboard — Source-reviewed capability benchmark",
|
||||
loc="left",
|
||||
fontsize=17,
|
||||
fontsize=15,
|
||||
fontweight="bold",
|
||||
color="#e6edf3",
|
||||
pad=28,
|
||||
|
||||
@@ -103,8 +103,8 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
ax.text(
|
||||
0,
|
||||
-0.22,
|
||||
"Unsupported tasks score 0 within a server's own matrix. The five v3 tasks were "
|
||||
"added after the competitor runs and were never put to them; per-task statuses are "
|
||||
"Unsupported tasks score 0 within a server's own matrix. The tasks added after v2 "
|
||||
"were never put to the pinned competitor runs; per-task statuses are "
|
||||
"in benchmarks/results/published/.",
|
||||
transform=ax.transAxes,
|
||||
color="#8b949e",
|
||||
|
||||
@@ -13,7 +13,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from benchmarks.tasks_v6 import TASKS_V6
|
||||
from benchmarks.tasks_v7 import TASKS_V7
|
||||
|
||||
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_V6]
|
||||
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V6]
|
||||
task_ids = [task.task_id for task in TASKS_V7]
|
||||
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V7]
|
||||
servers = [report["adapter"] for report in reports]
|
||||
status_by_server = {
|
||||
report["adapter"]: {item["task_id"]: item["status"] for item in report["results"]}
|
||||
@@ -133,8 +133,8 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
Patch(facecolor=STATUS_COLORS["fail"], label="fail"),
|
||||
Patch(facecolor=STATUS_COLORS["unsupported"], label="unsupported (no equivalent)"),
|
||||
# Real rows now, not a theoretical state: the competitor reports were
|
||||
# pinned and run against the v2 matrix, so the five v3 tasks were
|
||||
# never put to them. An invented zero would look identical to a
|
||||
# pinned and run against the v2 matrix, so every task added since v2
|
||||
# was never put to them. An invented zero would look identical to a
|
||||
# measured one, so they stay blank and say why.
|
||||
Patch(facecolor=STATUS_COLORS["not_run"], edgecolor="#30363d", label="not run"),
|
||||
]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"summary": {
|
||||
"baseline_ref": "v1.5.1",
|
||||
"total": 43,
|
||||
"total": 48,
|
||||
"old_pass": 26,
|
||||
"new_pass": 43,
|
||||
"old_pct": 60.5,
|
||||
"new_pass": 48,
|
||||
"old_pct": 54.2,
|
||||
"new_pct": 100.0,
|
||||
"fixed": 17,
|
||||
"fixed": 22,
|
||||
"regressed": 0
|
||||
},
|
||||
"rows": [
|
||||
@@ -267,6 +267,36 @@
|
||||
"category": "Architecture",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "scale_check_detects_schematic",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "pipe_takeoff_rmst_exact",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "cable_takeoff_roundup",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "diff_detects_known_edits",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "topology_known_defects",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,6 +25,11 @@
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"documents": {
|
||||
"mode": "registry",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"drawing_2d": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
@@ -35,11 +40,31 @@
|
||||
"reason": "no_dwg_writer_requires_live_autocad",
|
||||
"supported": false
|
||||
},
|
||||
"dwg_write": {
|
||||
"mode": null,
|
||||
"reason": "requires_oda_file_converter:https://www.opendesign.com/guestfiles/oda_file_converter",
|
||||
"supported": false
|
||||
},
|
||||
"dwgprops": {
|
||||
"mode": null,
|
||||
"reason": "summary_fields_need_live_autocad;custom_properties_need_r2004_or_newer",
|
||||
"supported": false
|
||||
},
|
||||
"dwt_write": {
|
||||
"mode": null,
|
||||
"reason": "dwt_is_a_dwg_container_requires_live_autocad",
|
||||
"supported": false
|
||||
},
|
||||
"dxf": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"explode_opaque_members": {
|
||||
"mode": null,
|
||||
"reason": "ezdxf_cannot_transform:OLE2FRAME,VIEWPORT,ACAD_PROXY_ENTITY_without_proxy_graphic;refused_before_writing",
|
||||
"supported": false
|
||||
},
|
||||
"handle_overlay": {
|
||||
"mode": "rendered",
|
||||
"reason": null,
|
||||
@@ -50,11 +75,26 @@
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"interactive_prompt": {
|
||||
"mode": null,
|
||||
"reason": "no_operator_to_prompt_headlessly",
|
||||
"supported": false
|
||||
},
|
||||
"layer_states": {
|
||||
"mode": "xrecord",
|
||||
"reason": "portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager",
|
||||
"supported": true
|
||||
},
|
||||
"lisp": {
|
||||
"mode": null,
|
||||
"reason": "live_com_only",
|
||||
"supported": false
|
||||
},
|
||||
"live_application": {
|
||||
"mode": null,
|
||||
"reason": "headless_engine_has_no_application_to_launch_or_attach",
|
||||
"supported": false
|
||||
},
|
||||
"measure_area_acis": {
|
||||
"mode": null,
|
||||
"reason": "acis_evaluation_requires_live_autocad",
|
||||
@@ -70,6 +110,11 @@
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"named_views": {
|
||||
"mode": "vport_active",
|
||||
"reason": "restore_sets_the_view_the_file_opens_on;no_live_display",
|
||||
"supported": true
|
||||
},
|
||||
"ocs_normalized": {
|
||||
"mode": "wcs",
|
||||
"reason": "reported_xy_is_wcs;z_component_not_reported;text_rotation_stays_in_entity_frame;hatch_boundaries_not_reported",
|
||||
@@ -95,6 +140,11 @@
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"preferences": {
|
||||
"mode": null,
|
||||
"reason": "preferences_live_in_the_running_application_not_in_a_file",
|
||||
"supported": false
|
||||
},
|
||||
"preflight": {
|
||||
"mode": "shared",
|
||||
"reason": null,
|
||||
@@ -105,6 +155,11 @@
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"registry_sysvar": {
|
||||
"mode": null,
|
||||
"reason": "registry_saved_variables_have_no_home_in_a_file",
|
||||
"supported": false
|
||||
},
|
||||
"revcloud": {
|
||||
"mode": "native",
|
||||
"reason": "ezdxf_revcloud_helper",
|
||||
@@ -125,6 +180,11 @@
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"ucs": {
|
||||
"mode": "stored_not_interpreted",
|
||||
"reason": "tool_coordinates_stay_wcs",
|
||||
"supported": true
|
||||
},
|
||||
"undo_history": {
|
||||
"mode": null,
|
||||
"reason": "set_EZDXF_UNDO_DEPTH_to_enable",
|
||||
@@ -139,6 +199,16 @@
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"xlsx_write": {
|
||||
"mode": "openpyxl",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"xref_live": {
|
||||
"mode": null,
|
||||
"reason": "reload_and_bind_need_a_live_seat;list_detach_path_are_headless",
|
||||
"supported": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,13 +219,13 @@
|
||||
"platform": "Windows-10-10.0.26200-SP0",
|
||||
"python": "3.11.15"
|
||||
},
|
||||
"generated_at": "2026-08-06T06:27:59.080169+00:00",
|
||||
"git_sha": "d5a0ff5109ddbd37d88371c10a1ed087201de4b5",
|
||||
"matrix": "v3",
|
||||
"generated_at": "2026-09-24T20:58:06.608758+00:00",
|
||||
"git_sha": "554daff8d08c7d891974e55db046e7c70c15e185",
|
||||
"matrix": "v7",
|
||||
"results": [
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 2.49,
|
||||
"duration_ms": 2.71,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"line_length": 5.0
|
||||
@@ -168,7 +238,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 1.41,
|
||||
"duration_ms": 1.92,
|
||||
"message": "",
|
||||
"metrics": {},
|
||||
"score": 100.0,
|
||||
@@ -179,7 +249,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 3.31,
|
||||
"duration_ms": 3.38,
|
||||
"message": "",
|
||||
"metrics": {},
|
||||
"score": 100.0,
|
||||
@@ -190,7 +260,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 17.55,
|
||||
"duration_ms": 24.95,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"entity_type": "DIMENSION"
|
||||
@@ -203,7 +273,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 3.7,
|
||||
"duration_ms": 3.64,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"representations": [
|
||||
@@ -219,7 +289,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 29.09,
|
||||
"duration_ms": 8.37,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"entity_count": 0
|
||||
@@ -232,7 +302,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 1.2,
|
||||
"duration_ms": 0.85,
|
||||
"message": "",
|
||||
"metrics": {},
|
||||
"score": 100.0,
|
||||
@@ -243,7 +313,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 8.42,
|
||||
"duration_ms": 17.45,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"final_score": 100.0,
|
||||
@@ -286,11 +356,11 @@
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "roundtrip.dxf",
|
||||
"sha256": "d11dfce5eddaf849075c62854a8526efaaa3bbd779d374e4c287de77f361caf7",
|
||||
"size_bytes": 18440
|
||||
"sha256": "28a18050aea00646f9ff7a5fdf8fe3b8783cd4cb77d71108b3834c746e7259c5",
|
||||
"size_bytes": 18446
|
||||
}
|
||||
],
|
||||
"duration_ms": 32.29,
|
||||
"duration_ms": 7.21,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"entity_count": 1
|
||||
@@ -305,11 +375,11 @@
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "manifest.json",
|
||||
"sha256": "a74b2e96dbe7ea373717f26a8d26e58fe079a61370eb5d646f04a09f1b7cca2f",
|
||||
"size_bytes": 5662
|
||||
"sha256": "7178055a030b533e83c4a914466c7474350e7728657cd624c9ebc01c753d9e13",
|
||||
"size_bytes": 7929
|
||||
}
|
||||
],
|
||||
"duration_ms": 305.81,
|
||||
"duration_ms": 274.87,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"score": {
|
||||
@@ -329,7 +399,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 1149.77,
|
||||
"duration_ms": 699.92,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"ranks": {
|
||||
@@ -350,15 +420,15 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 53.97,
|
||||
"duration_ms": 86.79,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"advertised_tools": 2,
|
||||
"catalog_tokens": 40305,
|
||||
"catalog_tools": 149,
|
||||
"catalog_tokens": 83921,
|
||||
"catalog_tools": 242,
|
||||
"ceiling": 2000,
|
||||
"discovery_tokens": 356,
|
||||
"reduction_x": 113.2,
|
||||
"reduction_x": 235.7,
|
||||
"tokenizer": "ratio/v1 (estimate)"
|
||||
},
|
||||
"score": 100.0,
|
||||
@@ -369,7 +439,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 10.6,
|
||||
"duration_ms": 7.47,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"area_ignoring_islands": 400.0,
|
||||
@@ -386,7 +456,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 8.0,
|
||||
"duration_ms": 26.1,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"bounding_box": 3,
|
||||
@@ -403,7 +473,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 5.36,
|
||||
"duration_ms": 5.83,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"boundary_agrees": true,
|
||||
@@ -417,16 +487,330 @@
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "measure_from_handle"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 58.19,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"authored_line_numbers_kept": true,
|
||||
"confidence_min": 1.0,
|
||||
"critique_issues": 0,
|
||||
"dangling": 0,
|
||||
"edges": 4,
|
||||
"instrument_index_rows": 1,
|
||||
"line_list_rows": 4,
|
||||
"nodes": 5,
|
||||
"unnumbered_lines_sequenced": true
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "pid_roundtrip"
|
||||
},
|
||||
{
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "untitled-Layout1-ansi_b.pdf",
|
||||
"sha256": "af3c19d4d8b14fbc116ab807bb6feee9fc900fc114e57cd6feb658a6c0dabffb",
|
||||
"size_bytes": 1287
|
||||
},
|
||||
{
|
||||
"filename": "untitled-Layout1-iso_a3.pdf",
|
||||
"sha256": "8d1d1ed505903b6f692ed6e91e9f52577c2c78db3bc2942b949ea4a3d73c9b01",
|
||||
"size_bytes": 1287
|
||||
}
|
||||
],
|
||||
"duration_ms": 65.46,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"mediabox_mm": [
|
||||
420.0,
|
||||
297.0
|
||||
],
|
||||
"mediabox_mm_before": [
|
||||
432.0,
|
||||
279.0
|
||||
],
|
||||
"orientation": "landscape",
|
||||
"paper": "ISO_A3",
|
||||
"pdf_bytes": 1287,
|
||||
"plot_style_known": true,
|
||||
"size_mm_changed": [
|
||||
[
|
||||
432.0,
|
||||
279.0
|
||||
],
|
||||
[
|
||||
420.0,
|
||||
297.0
|
||||
]
|
||||
],
|
||||
"size_mm_listed": [
|
||||
420.0,
|
||||
297.0
|
||||
]
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "page_setup_truth"
|
||||
},
|
||||
{
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "mech_assembly.dxf",
|
||||
"sha256": "407dcbed3eb17be7f9d72dda2c1951e8f97fa3fb6090713440404a3f1497d1b2",
|
||||
"size_bytes": 101158
|
||||
}
|
||||
],
|
||||
"duration_ms": 487.19,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"critique_focuses": [],
|
||||
"critique_issues": 0,
|
||||
"fasteners": 4,
|
||||
"grade": "A",
|
||||
"invalidity_ratio": 0.0,
|
||||
"parts_list_rows": 2,
|
||||
"score": 100.0,
|
||||
"validator_findings": []
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "mech_assembly"
|
||||
},
|
||||
{
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "arch_roundtrip.dxf",
|
||||
"sha256": "ebec378ef28670e091e651b4130a6eac5a0d2fb480d60f9335b37de28ea713c7",
|
||||
"size_bytes": 82829
|
||||
}
|
||||
],
|
||||
"duration_ms": 982.07,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"confidence_min": 1.0,
|
||||
"critique_focuses": [],
|
||||
"critique_issues": 0,
|
||||
"door_tags": [
|
||||
"D1",
|
||||
"D2"
|
||||
],
|
||||
"faces_detected": 2,
|
||||
"grade": "A",
|
||||
"invalidity_ratio": 0.0,
|
||||
"omitted": [],
|
||||
"rooms": {
|
||||
"01": {
|
||||
"agrees": true,
|
||||
"detected_mm2": 27743750.0,
|
||||
"hand_mm2": 27743750.0,
|
||||
"label_mm2": 27743750.0
|
||||
},
|
||||
"02": {
|
||||
"agrees": true,
|
||||
"detected_mm2": 21993750.0,
|
||||
"hand_mm2": 21993750.0,
|
||||
"label_mm2": 21993750.0
|
||||
}
|
||||
},
|
||||
"score": 100.0,
|
||||
"validator_findings": [],
|
||||
"window_tags": [
|
||||
"W1",
|
||||
"W2"
|
||||
]
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "arch_roundtrip"
|
||||
},
|
||||
{
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "pid.dxf",
|
||||
"sha256": "94dbdc026878927560a6c81e2283a7d2a26fbe6a398475d415c6383728c46672",
|
||||
"size_bytes": 33795
|
||||
},
|
||||
{
|
||||
"filename": "layout.dxf",
|
||||
"sha256": "37963e237f27f9f495b30d7c0b3bf7f4eff54ff4fec9665f34ab50ddc3f3c7a7",
|
||||
"size_bytes": 40503
|
||||
}
|
||||
],
|
||||
"duration_ms": 614.2,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"cable_mismatch": [],
|
||||
"length_source": "layout",
|
||||
"loads_checked": 4,
|
||||
"pipe_mismatch": [],
|
||||
"runs_checked": 9,
|
||||
"scale_verdict": "schematic",
|
||||
"within_10pct": 0.21818181818181817
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "takeoff_roundtrip"
|
||||
},
|
||||
{
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "layout.dxf",
|
||||
"sha256": "ff728965a5698cf9ddf9d9a01cfa244102d183065a149488d4d29dbcee404e85",
|
||||
"size_bytes": 40503
|
||||
},
|
||||
{
|
||||
"filename": "pid.dxf",
|
||||
"sha256": "de4f1ab4102d220efa7167b192b8fdaf9d57cbf300241e945c16967770401cd8",
|
||||
"size_bytes": 33795
|
||||
}
|
||||
],
|
||||
"duration_ms": 311.97,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"clusters": 2,
|
||||
"copies_separated": true,
|
||||
"misread_layers": {},
|
||||
"outlier_found": true,
|
||||
"units": {
|
||||
"agree": false,
|
||||
"confidence": 0.76,
|
||||
"declared": {
|
||||
"code": 1,
|
||||
"label": "Inches",
|
||||
"name": "in"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"as": {
|
||||
"cm": 2.5,
|
||||
"ft": 76.2,
|
||||
"in": 6.35,
|
||||
"m": 250.0,
|
||||
"mm": 0.25
|
||||
},
|
||||
"band": [
|
||||
0.0018,
|
||||
4.0
|
||||
],
|
||||
"band_unit": "m",
|
||||
"count": 26,
|
||||
"informative": true,
|
||||
"kind": "text_height",
|
||||
"plausible": [
|
||||
"mm",
|
||||
"cm"
|
||||
],
|
||||
"value": 250.0,
|
||||
"weight": 0.5
|
||||
},
|
||||
{
|
||||
"as": {
|
||||
"cm": 2.0,
|
||||
"ft": 60.96,
|
||||
"in": 5.08,
|
||||
"m": 200.0,
|
||||
"mm": 0.2
|
||||
},
|
||||
"band": [
|
||||
0.05,
|
||||
1.0
|
||||
],
|
||||
"band_unit": "m",
|
||||
"count": 24,
|
||||
"informative": true,
|
||||
"kind": "wall_thickness",
|
||||
"plausible": [
|
||||
"mm"
|
||||
],
|
||||
"value": 200.0,
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"as": {
|
||||
"cm": 21480.0,
|
||||
"ft": 19955572.992000002,
|
||||
"in": 138580.368,
|
||||
"m": 214800000.0,
|
||||
"mm": 214.79999999999998
|
||||
},
|
||||
"band": [
|
||||
1.0,
|
||||
5000.0
|
||||
],
|
||||
"band_unit": "m2",
|
||||
"count": 4,
|
||||
"informative": true,
|
||||
"kind": "room_area",
|
||||
"plausible": [
|
||||
"mm"
|
||||
],
|
||||
"value": 214800000.0,
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"as": {
|
||||
"cm": 1364.0,
|
||||
"ft": 41574.72,
|
||||
"in": 3464.56,
|
||||
"m": 136400.0,
|
||||
"mm": 136.4
|
||||
},
|
||||
"band": [
|
||||
0.01,
|
||||
2000.0
|
||||
],
|
||||
"band_unit": "m",
|
||||
"count": 80,
|
||||
"informative": true,
|
||||
"kind": "overall_size",
|
||||
"plausible": [
|
||||
"mm",
|
||||
"cm"
|
||||
],
|
||||
"value": 136400.0,
|
||||
"weight": 0.25
|
||||
}
|
||||
],
|
||||
"inferred": "mm",
|
||||
"mm_per_unit": 1.0,
|
||||
"scores": {
|
||||
"cm": 0.75,
|
||||
"ft": 0,
|
||||
"in": 0.5,
|
||||
"m": 0,
|
||||
"mm": 2.75
|
||||
},
|
||||
"warning": "INSUNITS declares Inches (1) but the geometry reads as mm: text_height 250 = 0.25 m as mm, 6.35 m as in; wall_thickness 200 = 0.2 m as mm, 5.08 m as in; room_area 2.148e+08 = 214.8 m2 as mm, 1.386e+05 m2 as in; overall_size 136400 = 136.4 m as mm, 3465 m as in. Lengths are measured in mm; the header is not changed."
|
||||
},
|
||||
"units_flagged": true
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "understand_foreign"
|
||||
}
|
||||
],
|
||||
"schema_version": "2.0",
|
||||
"summary": {
|
||||
"attempted": 15,
|
||||
"attempted": 21,
|
||||
"coverage_percent": 100.0,
|
||||
"passed": 15,
|
||||
"passed": 21,
|
||||
"score": 100.0,
|
||||
"supported": 15,
|
||||
"supported": 21,
|
||||
"unsupported": 0
|
||||
},
|
||||
"timeout_seconds": 180.0
|
||||
"timeout_seconds": 30.0
|
||||
}
|
||||
|
||||
@@ -14,23 +14,37 @@
|
||||
"entity_count": 2000
|
||||
},
|
||||
"metric": "entities_per_second",
|
||||
"metric_value": 5715.8,
|
||||
"wall_ms": 349.9,
|
||||
"workload_id": "create_lines_2k"
|
||||
"wall_ms": 306.1,
|
||||
"workload_id": "create_lines_2k",
|
||||
"runs_ms": [
|
||||
287.6,
|
||||
373.6,
|
||||
298.2,
|
||||
425.1,
|
||||
306.1
|
||||
],
|
||||
"metric_value": 6533.8
|
||||
},
|
||||
{
|
||||
"description": "Create 10,000 lines, export DXF, reopen and verify",
|
||||
"detail": {
|
||||
"build_ms": 1633.1,
|
||||
"build_ms": 2149.4,
|
||||
"entity_count": 10000,
|
||||
"export_ms": 273.0,
|
||||
"file_bytes": 1369663,
|
||||
"reopen_ms": 272.1
|
||||
"export_ms": 516.6,
|
||||
"file_bytes": 1369662,
|
||||
"reopen_ms": 535.2
|
||||
},
|
||||
"metric": "entities_per_second",
|
||||
"metric_value": 4590.8,
|
||||
"wall_ms": 2178.3,
|
||||
"workload_id": "roundtrip_10k"
|
||||
"wall_ms": 2553.5,
|
||||
"workload_id": "roundtrip_10k",
|
||||
"runs_ms": [
|
||||
3201.3,
|
||||
2550.0,
|
||||
3118.3,
|
||||
2553.5,
|
||||
2289.4
|
||||
],
|
||||
"metric_value": 3916.2
|
||||
},
|
||||
{
|
||||
"description": "Crossing-selection region query over 10,000 entities",
|
||||
@@ -39,8 +53,15 @@
|
||||
},
|
||||
"metric": "entities_matched",
|
||||
"metric_value": 2500.0,
|
||||
"wall_ms": 2466.9,
|
||||
"workload_id": "region_query_10k"
|
||||
"wall_ms": 712.2,
|
||||
"workload_id": "region_query_10k",
|
||||
"runs_ms": [
|
||||
712.2,
|
||||
806.7,
|
||||
295.1,
|
||||
836.4,
|
||||
300.6
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Plate with hole: ISO layers, geometry, dimensions and full critique",
|
||||
@@ -49,8 +70,16 @@
|
||||
},
|
||||
"metric": "critique_issues",
|
||||
"metric_value": 0.0,
|
||||
"wall_ms": 255.7,
|
||||
"workload_id": "premium_pass"
|
||||
"wall_ms": 65.3,
|
||||
"workload_id": "premium_pass",
|
||||
"runs_ms": [
|
||||
76.3,
|
||||
65.3,
|
||||
36.9,
|
||||
83.8,
|
||||
41.3
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"aggregation": "median of 5 default-configuration runs, one machine, one interpreter"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"schema_version": "perf-ab/1.0",
|
||||
"method": "benchmarks/perf_suite.py of 1.6.0 run against each tree's own code with one CPython 3.11.15 and one ezdxf, five rounds, the three configurations alternating within a round; medians of wall_ms. v1.4.0 had no perf suite; the 1.6.0 script drives the same backend methods.",
|
||||
"environment": {
|
||||
"implementation": "CPython",
|
||||
"machine": "AMD64",
|
||||
"platform": "Windows-10-10.0.26200-SP0",
|
||||
"python": "3.11.15"
|
||||
},
|
||||
"medians_ms": {
|
||||
"v1.4.0": {
|
||||
"create_lines_2k": 244.5,
|
||||
"roundtrip_10k": 1847.9,
|
||||
"region_query_10k": 665.2,
|
||||
"premium_pass": 150.6
|
||||
},
|
||||
"v1.6.0": {
|
||||
"create_lines_2k": 306.1,
|
||||
"roundtrip_10k": 2553.5,
|
||||
"region_query_10k": 712.2,
|
||||
"premium_pass": 65.3
|
||||
},
|
||||
"v1.6.0 EZDXF_CALL_TIMEOUT=0": {
|
||||
"create_lines_2k": 284.9,
|
||||
"roundtrip_10k": 2216.4,
|
||||
"region_query_10k": 783.9,
|
||||
"premium_pass": 69.9
|
||||
}
|
||||
},
|
||||
"ratios_to_v1_4_0": {
|
||||
"v1.6.0": {
|
||||
"create_lines_2k": 1.25,
|
||||
"roundtrip_10k": 1.38,
|
||||
"region_query_10k": 1.07,
|
||||
"premium_pass": 0.43
|
||||
},
|
||||
"v1.6.0 EZDXF_CALL_TIMEOUT=0": {
|
||||
"create_lines_2k": 1.17,
|
||||
"roundtrip_10k": 1.2,
|
||||
"region_query_10k": 1.18,
|
||||
"premium_pass": 0.46
|
||||
}
|
||||
},
|
||||
"runs": {
|
||||
"v1.4.0": [
|
||||
{
|
||||
"create_lines_2k": 244.5,
|
||||
"roundtrip_10k": 1936.8,
|
||||
"region_query_10k": 670.3,
|
||||
"premium_pass": 160.3
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 221.9,
|
||||
"roundtrip_10k": 1592.5,
|
||||
"region_query_10k": 638.2,
|
||||
"premium_pass": 150.6
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 236.7,
|
||||
"roundtrip_10k": 1706.6,
|
||||
"region_query_10k": 927.2,
|
||||
"premium_pass": 140.2
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 261.0,
|
||||
"roundtrip_10k": 1979.2,
|
||||
"region_query_10k": 665.2,
|
||||
"premium_pass": 191.4
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 308.4,
|
||||
"roundtrip_10k": 1847.9,
|
||||
"region_query_10k": 296.5,
|
||||
"premium_pass": 70.3
|
||||
}
|
||||
],
|
||||
"v1.6.0": [
|
||||
{
|
||||
"create_lines_2k": 287.6,
|
||||
"roundtrip_10k": 3201.3,
|
||||
"region_query_10k": 712.2,
|
||||
"premium_pass": 76.3
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 373.6,
|
||||
"roundtrip_10k": 2550.0,
|
||||
"region_query_10k": 806.7,
|
||||
"premium_pass": 65.3
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 298.2,
|
||||
"roundtrip_10k": 3118.3,
|
||||
"region_query_10k": 295.1,
|
||||
"premium_pass": 36.9
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 425.1,
|
||||
"roundtrip_10k": 2553.5,
|
||||
"region_query_10k": 836.4,
|
||||
"premium_pass": 83.8
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 306.1,
|
||||
"roundtrip_10k": 2289.4,
|
||||
"region_query_10k": 300.6,
|
||||
"premium_pass": 41.3
|
||||
}
|
||||
],
|
||||
"v1.6.0 EZDXF_CALL_TIMEOUT=0": [
|
||||
{
|
||||
"create_lines_2k": 285.3,
|
||||
"roundtrip_10k": 1817.2,
|
||||
"region_query_10k": 926.5,
|
||||
"premium_pass": 61.4
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 256.1,
|
||||
"roundtrip_10k": 2520.9,
|
||||
"region_query_10k": 750.3,
|
||||
"premium_pass": 80.2
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 260.2,
|
||||
"roundtrip_10k": 1714.1,
|
||||
"region_query_10k": 783.9,
|
||||
"premium_pass": 76.7
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 351.7,
|
||||
"roundtrip_10k": 3057.1,
|
||||
"region_query_10k": 940.8,
|
||||
"premium_pass": 69.9
|
||||
},
|
||||
{
|
||||
"create_lines_2k": 284.9,
|
||||
"roundtrip_10k": 2216.4,
|
||||
"region_query_10k": 311.2,
|
||||
"premium_pass": 41.7
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
|
||||
from backends.capability import UnsupportedCapabilityError
|
||||
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
|
||||
from benchmarks.tasks_v6 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id
|
||||
from benchmarks.tasks_v7 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id
|
||||
|
||||
|
||||
def _load_registry() -> dict[str, dict]:
|
||||
|
||||
@@ -1,25 +1,88 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"reviewed_at": "2026-07-23",
|
||||
"reviewed_at": "2026-09-24",
|
||||
"title": "AutoCAD MCP source-reviewed capability benchmark",
|
||||
"evidence_boundary": "source_review_not_shared_live_run",
|
||||
"methodology": "Public source, documented behavior, and locally recorded test evidence scored with a fixed 100-point rubric. Repository stars and raw tool counts do not score points.",
|
||||
"methodology": "Public source, documented behavior, and locally recorded test evidence scored with a fixed 100-point rubric. Repository stars and raw tool counts do not score points. The U-C4N row was re-scored for 1.6.0 on 2026-09-24 with per-category evidence; competitor rows are their July snapshots and were not re-reviewed.",
|
||||
"rubric": [
|
||||
{"category": "Functional CAD coverage", "weight": 25},
|
||||
{"category": "Correctness and delivery", "weight": 20},
|
||||
{"category": "Backends and platforms", "weight": 15},
|
||||
{"category": "Engineering production", "weight": 15},
|
||||
{"category": "Tests and maintenance", "weight": 15},
|
||||
{"category": "Security and operations", "weight": 10}
|
||||
{
|
||||
"category": "Functional CAD coverage",
|
||||
"weight": 25
|
||||
},
|
||||
{
|
||||
"category": "Correctness and delivery",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"category": "Backends and platforms",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"category": "Engineering production",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"category": "Tests and maintenance",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"category": "Security and operations",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"repository": "U-C4N/Autocad-MCP",
|
||||
"url": "https://github.com/U-C4N/Autocad-MCP",
|
||||
"reviewed_version": "1.4.0 (PyPI release)",
|
||||
"score": 95,
|
||||
"reviewed_version": "1.6.0 (release)",
|
||||
"score": 93,
|
||||
"evidence_grade": "A",
|
||||
"note": "Dual COM/ezdxf backends, paper space, ISO 129/286/1101 annotation, closed-loop quality gates, auditable delivery; v1.4 adds CI matrix incl. Windows, PyPI packaging, and a version A/B correctness gate (21/21, 0 regressions)."
|
||||
"note": "Scored per category, each with its evidence (roadmap criterion 8). 1.4.0 received a single overall 95; this review is stricter, and every point withheld is named. New in 1.5-1.6: P&ID, environment, mechanical, sheet, architecture, understanding and takeoffs; A/B 48/48 with 0 regressions; live COM smokes on every track.",
|
||||
"category_scores": [
|
||||
{
|
||||
"category": "Functional CAD coverage",
|
||||
"weight": 25,
|
||||
"score": 24,
|
||||
"evidence": "247 tools: drafting, ISO 129/286/1101 annotation, paper space, styles and page setup, environment, P&ID (ISO 10628-2 / ISA-5.1, read back with confidence), a mechanical part model with standard parts, ISO 5457/7200/7573/6433 sheets, an architectural plan engine, drawing understanding, diff, topology and plant takeoffs. Not covered: electrical schematics, image-to-CAD tracing, Plant 3D, obstacle-avoiding routing, headless REGION/3DSOLID."
|
||||
},
|
||||
{
|
||||
"category": "Correctness and delivery",
|
||||
"weight": 20,
|
||||
"score": 19,
|
||||
"evidence": "A/B gate against v1.5.1: 48/48 checks, 0 regressions (ab-v1.5.1-vs-v1.6.0.json). Matrix v7: 21 tasks whose gates can fail (zero critique issues and a finalize score of at least 90, PDF /MediaBox, rooms within 0.1 %, takeoff lengths exact). drawing_deliver writes a manifest and checks reopen parity. A field test on a real P&ID pair drove nine fixes. Minus: a headless DIMENSION does not re-render, and a takeoff on a real P&ID still needs the engineer's scope decisions."
|
||||
},
|
||||
{
|
||||
"category": "Backends and platforms",
|
||||
"weight": 15,
|
||||
"score": 14,
|
||||
"evidence": "COM and ezdxf behind one typed contract, with capability-map parity enforced by a test. Every 1.6 track's COM path ran live on AutoCAD 2026. CI runs Linux 3.11/3.12 and Windows. Minus: BricsCAD, ZWCAD and GstarCAD are unverified."
|
||||
},
|
||||
{
|
||||
"category": "Engineering production",
|
||||
"weight": 15,
|
||||
"score": 14,
|
||||
"evidence": "Transcribed standards tables (ISO 4014/4017/4032/7089/4762, ISO 15, ISO 286, ISO 5457, ISO 7200, ISO 7573) and one part model and view engine. 18 domain critique focuses (P&ID 6, mechanical 6, architecture 3, topology 3) feed drawing_finalize's 0-100 score. Minus: four DIN/ISO feature tables ship empty until verified rows exist."
|
||||
},
|
||||
{
|
||||
"category": "Tests and maintenance",
|
||||
"weight": 15,
|
||||
"score": 14,
|
||||
"evidence": "About 4,990 tests on a locked dependency graph. The release gate re-runs three OS/Python lanes; a document-number gate and the per-release A/B suite also run. The perf lane is published (perf-v1.4.0-vs-v1.6.0.json). Minus: entity creation is 1.25x (2,000 lines) and 1.38x (10,000-line roundtrip) slower than v1.4.0, so roadmap criterion 7 is not met."
|
||||
},
|
||||
{
|
||||
"category": "Security and operations",
|
||||
"weight": 10,
|
||||
"score": 8,
|
||||
"evidence": "Per-tool path validation, a loopback-only HTTP bind unless a bearer token is set, the audit middleware, and a field-test script that refuses to write inside the repository. Minus: ALLOWED_PATHS is empty, and so unscoped, by default; command and LISP filtering is a denylist, which is a guardrail rather than a security boundary."
|
||||
}
|
||||
],
|
||||
"history": [
|
||||
{
|
||||
"version": "1.4.0",
|
||||
"score": 95,
|
||||
"reviewed_at": "2026-07-23"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repository": "varavista/autocad-mcp",
|
||||
@@ -85,5 +148,6 @@
|
||||
"evidence_grade": "C",
|
||||
"note": "Basic COM drawing tool set with SQLite persistence; reviewed from the documented tool surface only, execution path not exercised locally."
|
||||
}
|
||||
]
|
||||
],
|
||||
"competitors_reviewed_at": "2026-07-23"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""The v7 task matrix: v6 plus what track H added.
|
||||
|
||||
Track H teaches the server to read drawings somebody else made, so its two
|
||||
tasks are gated on a drawing pair whose answers are known in advance - the
|
||||
synthetic plant pair of ``tests/fixtures/plant_pair.py``, which reproduces
|
||||
every field finding of the design spec with invented names:
|
||||
|
||||
* ``takeoff_roundtrip`` - the scale check calls the P&ID ``schematic``; every
|
||||
routable pipe run's layout length equals the rectilinear minimum spanning
|
||||
tree of its tags exactly; its size, service and status match the generator's
|
||||
truth; the cable rows equal the Manhattan lengths from each load to its panel
|
||||
with the allowance rounded up to the metre.
|
||||
* ``understand_foreign`` - one ``describe`` of the layout flags inches declared
|
||||
over millimetre geometry, finds the stray entity by handle and separates the
|
||||
two plan copies; one ``describe`` of the P&ID classifies its English and
|
||||
Dutch service layers.
|
||||
|
||||
The published competitor reports were never asked these questions; 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
|
||||
from benchmarks.tasks_v5 import TASKS_V5
|
||||
from benchmarks.tasks_v6 import TASKS_V6, TaskSpec
|
||||
|
||||
#: Added in v1.6.0 by track H.
|
||||
NEW_TASKS_V7: tuple[TaskSpec, ...] = (
|
||||
TaskSpec(
|
||||
"takeoff_roundtrip",
|
||||
"plant",
|
||||
"Take pipe and cable quantities off a schematic P&ID and its layout: the P&ID "
|
||||
"called schematic, every run's layout length equal to the rectilinear MST of "
|
||||
"its tags, sizes and services as drawn, cable metres rounded up after the "
|
||||
"allowance",
|
||||
1.0,
|
||||
),
|
||||
TaskSpec(
|
||||
"understand_foreign",
|
||||
"understand",
|
||||
"Read a foreign layout in one call: inches declared over millimetres flagged, "
|
||||
"the stray entity found, both plan copies separated, the service layers of its "
|
||||
"P&ID classified",
|
||||
1.0,
|
||||
),
|
||||
)
|
||||
|
||||
TASKS_V7: tuple[TaskSpec, ...] = TASKS_V6 + NEW_TASKS_V7
|
||||
|
||||
#: Runner-selectable task sets. v2 to v6 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,
|
||||
"v6": TASKS_V6,
|
||||
"v7": TASKS_V7,
|
||||
}
|
||||
|
||||
DEFAULT_MATRIX = "v7"
|
||||
|
||||
|
||||
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}")
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 90 KiB |
@@ -30,8 +30,8 @@ z
|
||||
<g id="axes_1">
|
||||
<g id="patch_2">
|
||||
<path d="M 141.004928 288.250222
|
||||
L 468.991155 288.250222
|
||||
L 468.991155 44.677428
|
||||
L 582.287093 288.250222
|
||||
L 582.287093 44.677428
|
||||
L 141.004928 44.677428
|
||||
z
|
||||
" style="fill: #0d1117"/>
|
||||
@@ -41,7 +41,7 @@ z
|
||||
<g id="line2d_1">
|
||||
<path d="M 141.004928 288.250222
|
||||
L 141.004928 44.677428
|
||||
" clip-path="url(#p901540caf8)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
" clip-path="url(#p09509dacb4)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="line2d_2">
|
||||
<defs>
|
||||
@@ -85,18 +85,18 @@ z
|
||||
</g>
|
||||
<g id="xtick_2">
|
||||
<g id="line2d_3">
|
||||
<path d="M 206.602173 288.250222
|
||||
L 206.602173 44.677428
|
||||
" clip-path="url(#p901540caf8)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
<path d="M 229.261361 288.250222
|
||||
L 229.261361 44.677428
|
||||
" clip-path="url(#p09509dacb4)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="line2d_4">
|
||||
<g>
|
||||
<use xlink:href="#m23c3ac318b" x="206.602173" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
<use xlink:href="#m23c3ac318b" x="229.261361" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_2">
|
||||
<!-- 20 -->
|
||||
<g style="fill: #8b949e" transform="translate(200.239673 302.847879) scale(0.1 -0.1)">
|
||||
<g style="fill: #8b949e" transform="translate(222.898861 302.847879) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-15" d="M 1228 531
|
||||
L 3431 531
|
||||
@@ -130,18 +130,18 @@ z
|
||||
</g>
|
||||
<g id="xtick_3">
|
||||
<g id="line2d_5">
|
||||
<path d="M 272.199419 288.250222
|
||||
L 272.199419 44.677428
|
||||
" clip-path="url(#p901540caf8)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
<path d="M 317.517794 288.250222
|
||||
L 317.517794 44.677428
|
||||
" clip-path="url(#p09509dacb4)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="line2d_6">
|
||||
<g>
|
||||
<use xlink:href="#m23c3ac318b" x="272.199419" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
<use xlink:href="#m23c3ac318b" x="317.517794" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_3">
|
||||
<!-- 40 -->
|
||||
<g style="fill: #8b949e" transform="translate(265.836919 302.847879) scale(0.1 -0.1)">
|
||||
<g style="fill: #8b949e" transform="translate(311.155294 302.847879) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-17" d="M 2419 4116
|
||||
L 825 1625
|
||||
@@ -170,18 +170,18 @@ z
|
||||
</g>
|
||||
<g id="xtick_4">
|
||||
<g id="line2d_7">
|
||||
<path d="M 337.796664 288.250222
|
||||
L 337.796664 44.677428
|
||||
" clip-path="url(#p901540caf8)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
<path d="M 405.774227 288.250222
|
||||
L 405.774227 44.677428
|
||||
" clip-path="url(#p09509dacb4)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="line2d_8">
|
||||
<g>
|
||||
<use xlink:href="#m23c3ac318b" x="337.796664" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
<use xlink:href="#m23c3ac318b" x="405.774227" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_4">
|
||||
<!-- 60 -->
|
||||
<g style="fill: #8b949e" transform="translate(331.434164 302.847879) scale(0.1 -0.1)">
|
||||
<g style="fill: #8b949e" transform="translate(399.411727 302.847879) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-19" d="M 2113 2584
|
||||
Q 1688 2584 1439 2293
|
||||
@@ -221,18 +221,18 @@ z
|
||||
</g>
|
||||
<g id="xtick_5">
|
||||
<g id="line2d_9">
|
||||
<path d="M 403.39391 288.250222
|
||||
L 403.39391 44.677428
|
||||
" clip-path="url(#p901540caf8)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
<path d="M 494.03066 288.250222
|
||||
L 494.03066 44.677428
|
||||
" clip-path="url(#p09509dacb4)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="line2d_10">
|
||||
<g>
|
||||
<use xlink:href="#m23c3ac318b" x="403.39391" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
<use xlink:href="#m23c3ac318b" x="494.03066" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_5">
|
||||
<!-- 80 -->
|
||||
<g style="fill: #8b949e" transform="translate(397.03141 302.847879) scale(0.1 -0.1)">
|
||||
<g style="fill: #8b949e" transform="translate(487.66816 302.847879) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-1b" d="M 2034 2216
|
||||
Q 1584 2216 1326 1975
|
||||
@@ -281,18 +281,18 @@ z
|
||||
</g>
|
||||
<g id="xtick_6">
|
||||
<g id="line2d_11">
|
||||
<path d="M 468.991155 288.250222
|
||||
L 468.991155 44.677428
|
||||
" clip-path="url(#p901540caf8)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
<path d="M 582.287093 288.250222
|
||||
L 582.287093 44.677428
|
||||
" clip-path="url(#p09509dacb4)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="line2d_12">
|
||||
<g>
|
||||
<use xlink:href="#m23c3ac318b" x="468.991155" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
<use xlink:href="#m23c3ac318b" x="582.287093" y="288.250222" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_6">
|
||||
<!-- 100 -->
|
||||
<g style="fill: #8b949e" transform="translate(459.447405 302.847879) scale(0.1 -0.1)">
|
||||
<g style="fill: #8b949e" transform="translate(572.743343 302.847879) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-14" d="M 794 531
|
||||
L 1825 531
|
||||
@@ -317,7 +317,7 @@ z
|
||||
</g>
|
||||
<g id="text_7">
|
||||
<!-- Weighted live-run score / 100, within each server's own task matrix -->
|
||||
<g style="fill: #e6edf3" transform="translate(136.00351 322.84866) scale(0.1 -0.1)">
|
||||
<g style="fill: #e6edf3" transform="translate(192.651479 322.84866) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-3a" d="M 213 4666
|
||||
L 850 4666
|
||||
@@ -1007,27 +1007,27 @@ z
|
||||
</g>
|
||||
<g id="patch_3">
|
||||
<path d="M 141.004928 277.178732
|
||||
L 288.59873 277.178732
|
||||
L 288.59873 227.399936
|
||||
L 339.581902 277.178732
|
||||
L 339.581902 227.399936
|
||||
L 141.004928 227.399936
|
||||
z
|
||||
" clip-path="url(#p901540caf8)" style="fill: #484f58"/>
|
||||
" clip-path="url(#p09509dacb4)" style="fill: #484f58"/>
|
||||
</g>
|
||||
<g id="patch_4">
|
||||
<path d="M 141.004928 191.353223
|
||||
L 304.998041 191.353223
|
||||
L 304.998041 141.574427
|
||||
L 361.64601 191.353223
|
||||
L 361.64601 141.574427
|
||||
L 141.004928 141.574427
|
||||
z
|
||||
" clip-path="url(#p901540caf8)" style="fill: #484f58"/>
|
||||
" clip-path="url(#p09509dacb4)" style="fill: #484f58"/>
|
||||
</g>
|
||||
<g id="patch_5">
|
||||
<path d="M 141.004928 105.527713
|
||||
L 468.991155 105.527713
|
||||
L 468.991155 55.748918
|
||||
L 582.287093 105.527713
|
||||
L 582.287093 55.748918
|
||||
L 141.004928 55.748918
|
||||
z
|
||||
" clip-path="url(#p901540caf8)" style="fill: #58a6ff"/>
|
||||
" clip-path="url(#p09509dacb4)" style="fill: #58a6ff"/>
|
||||
</g>
|
||||
<g id="text_11">
|
||||
<!-- Same harness, pinned competitor commits; artifacts verified by re-opening the DXF. Matrices differ - read the per-bar label. -->
|
||||
@@ -1378,7 +1378,7 @@ z
|
||||
</g>
|
||||
<g id="text_12">
|
||||
<!-- 45 (4/10 pass, 50% coverage, matrix v2) -->
|
||||
<g style="fill: #e6edf3" transform="translate(292.534565 254.887381) scale(0.1 -0.1)">
|
||||
<g style="fill: #e6edf3" transform="translate(344.877288 254.887381) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-Bold-17" d="M 2356 3675
|
||||
L 1038 1722
|
||||
@@ -1915,7 +1915,7 @@ z
|
||||
</g>
|
||||
<g id="text_13">
|
||||
<!-- 50 (5/10 pass, 50% coverage, matrix v2) -->
|
||||
<g style="fill: #e6edf3" transform="translate(308.933876 169.061872) scale(0.1 -0.1)">
|
||||
<g style="fill: #e6edf3" transform="translate(366.941396 169.061872) scale(0.1 -0.1)">
|
||||
<use xlink:href="#DejaVuSans-Bold-18"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-13" transform="translate(69.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(139.15625 0)"/>
|
||||
@@ -1959,39 +1959,17 @@ z
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_14">
|
||||
<!-- 100 (15/15 pass, 100% coverage, matrix v3) -->
|
||||
<g style="fill: #e6edf3" transform="translate(390.27446 83.236363) scale(0.1 -0.1)">
|
||||
<!-- 100 (21/21 pass, 100% coverage, matrix v7) -->
|
||||
<g style="fill: #e6edf3" transform="translate(476.379373 83.236363) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-Bold-16" d="M 2981 2516
|
||||
Q 3453 2394 3698 2092
|
||||
Q 3944 1791 3944 1325
|
||||
Q 3944 631 3412 270
|
||||
Q 2881 -91 1863 -91
|
||||
Q 1503 -91 1142 -33
|
||||
Q 781 25 428 141
|
||||
L 428 1069
|
||||
Q 766 900 1098 814
|
||||
Q 1431 728 1753 728
|
||||
Q 2231 728 2486 893
|
||||
Q 2741 1059 2741 1369
|
||||
Q 2741 1688 2480 1852
|
||||
Q 2219 2016 1709 2016
|
||||
L 1228 2016
|
||||
L 1228 2791
|
||||
L 1734 2791
|
||||
Q 2188 2791 2409 2933
|
||||
Q 2631 3075 2631 3366
|
||||
Q 2631 3634 2415 3781
|
||||
Q 2200 3928 1806 3928
|
||||
Q 1516 3928 1219 3862
|
||||
Q 922 3797 628 3669
|
||||
L 628 4550
|
||||
Q 984 4650 1334 4700
|
||||
Q 1684 4750 2022 4750
|
||||
Q 2931 4750 3382 4451
|
||||
Q 3834 4153 3834 3553
|
||||
Q 3834 3144 3618 2883
|
||||
Q 3403 2622 2981 2516
|
||||
<path id="DejaVuSans-Bold-1a" d="M 428 4666
|
||||
L 3944 4666
|
||||
L 3944 3988
|
||||
L 2125 0
|
||||
L 953 0
|
||||
L 2675 3781
|
||||
L 428 3781
|
||||
L 428 4666
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
@@ -2001,11 +1979,11 @@ z
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(208.734375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(243.546875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-b" transform="translate(278.359375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-14" transform="translate(324.0625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-18" transform="translate(393.640625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-15" transform="translate(324.0625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-14" transform="translate(393.640625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-12" transform="translate(463.21875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-14" transform="translate(499.75 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-18" transform="translate(569.328125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-15" transform="translate(499.75 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-14" transform="translate(569.328125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(638.90625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-53" transform="translate(673.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-44" transform="translate(745.296875 0)"/>
|
||||
@@ -2036,12 +2014,12 @@ z
|
||||
<use xlink:href="#DejaVuSans-Bold-5b" transform="translate(2241.4375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(2305.9375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-59" transform="translate(2340.75 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-16" transform="translate(2405.9375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-1a" transform="translate(2405.9375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-c" transform="translate(2475.515625 0)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_15">
|
||||
<!-- Unsupported tasks score 0 within a server's own matrix. The five v3 tasks were added after the competitor runs and were never put to them; per-task statuses are in benchmarks/results/published/. -->
|
||||
<!-- Unsupported tasks score 0 within a server's own matrix. The tasks added after v2 were never put to the pinned competitor runs; per-task statuses are in benchmarks/results/published/. -->
|
||||
<g style="fill: #8b949e" transform="translate(141.004928 341.836237) scale(0.09 -0.09)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-38" d="M 556 4666
|
||||
@@ -2133,139 +2111,128 @@ z
|
||||
<use xlink:href="#DejaVuSans-4b" transform="translate(2912.34375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(2975.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(3037.25 0)"/>
|
||||
<use xlink:href="#DejaVuSans-13af" transform="translate(3069.03125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-59" transform="translate(3132.015625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(3191.203125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(3252.734375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-59" transform="translate(3284.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-16" transform="translate(3343.703125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(3407.328125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(3439.109375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(3478.3125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(3539.59375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4e" transform="translate(3591.6875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(3649.59375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(3701.6875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-5a" transform="translate(3733.46875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(3815.25 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(3876.78125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(3915.6875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(3977.21875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(4009 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(4070.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(4133.765625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(4197.25 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(4258.78125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(4322.265625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(4354.046875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-49" transform="translate(4415.328125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(4448.78125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(4487.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(4549.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(4590.625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(4622.40625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4b" transform="translate(4661.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(4724.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(4786.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-46" transform="translate(4818.296875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(4873.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-50" transform="translate(4934.46875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-53" transform="translate(5031.875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(5095.359375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(5156.890625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(5196.09375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(5223.875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(5263.078125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(5324.265625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(5365.375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(5397.15625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(5438.265625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(5501.640625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(5565.015625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(5617.109375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(5648.890625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(5710.171875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(5773.546875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(5837.03125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-5a" transform="translate(5868.8125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(5950.59375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(6012.125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(6051.03125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(6112.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(6144.34375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(6207.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-59" transform="translate(6269.25 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(6328.4375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(6389.96875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(6431.078125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-53" transform="translate(6462.859375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(6526.34375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(6589.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(6628.921875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(6660.703125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(6699.90625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(6761.09375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(6792.875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4b" transform="translate(6832.078125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(6895.453125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-50" transform="translate(6956.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-1e" transform="translate(7054.390625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(7088.078125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-53" transform="translate(7119.859375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(7183.34375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(7244.875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-10" transform="translate(7279.59375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(7315.671875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(7354.875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(7416.15625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4e" transform="translate(7468.25 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(7526.15625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(7557.9375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(7610.03125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(7649.234375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(7710.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(7749.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(7813.09375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(7865.1875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(7926.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(7978.8125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(8010.59375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(8071.875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(8110.78125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(8172.3125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(8204.09375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(8231.875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(8295.25 0)"/>
|
||||
<use xlink:href="#DejaVuSans-45" transform="translate(8327.03125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(8390.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(8452.046875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-46" transform="translate(8515.421875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4b" transform="translate(8570.40625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-50" transform="translate(8633.78125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(8731.1875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(8792.46875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4e" transform="translate(8833.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(8891.484375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-12" transform="translate(8943.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(8977.265625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(9016.171875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(9077.703125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(9129.796875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4f" transform="translate(9193.171875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(9220.953125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(9260.15625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-12" transform="translate(9312.25 0)"/>
|
||||
<use xlink:href="#DejaVuSans-53" transform="translate(9345.9375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(9409.421875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-45" transform="translate(9472.796875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4f" transform="translate(9536.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(9564.0625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(9591.84375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4b" transform="translate(9643.9375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(9707.3125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(9768.84375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-12" transform="translate(9832.328125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-11" transform="translate(9866.015625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(3069.03125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(3108.234375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(3169.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4e" transform="translate(3221.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(3279.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(3331.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(3363.390625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(3424.671875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(3488.15625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(3551.640625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(3613.171875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(3676.65625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(3708.4375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-49" transform="translate(3769.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(3803.171875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(3842.375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(3903.90625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(3945.015625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-59" transform="translate(3976.796875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-15" transform="translate(4035.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(4099.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-5a" transform="translate(4131.390625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(4213.171875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(4274.703125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(4313.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(4375.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(4406.921875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(4470.296875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-59" transform="translate(4531.828125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(4591.015625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(4652.546875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(4693.65625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-53" transform="translate(4725.4375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(4788.921875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(4852.296875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(4891.5 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(4923.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(4962.484375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(5023.671875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(5055.453125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4b" transform="translate(5094.65625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(5158.03125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(5219.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-53" transform="translate(5251.34375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(5314.828125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(5342.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(5405.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(5469.359375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(5530.890625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(5594.375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-46" transform="translate(5626.15625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(5681.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-50" transform="translate(5742.328125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-53" transform="translate(5839.734375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(5903.21875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(5964.75 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(6003.953125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(6031.734375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(6070.9375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(6132.125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(6173.234375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(6205.015625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(6246.125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(6309.5 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(6372.875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-1e" transform="translate(6424.96875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(6458.65625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-53" transform="translate(6490.4375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(6553.921875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(6615.453125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-10" transform="translate(6650.171875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(6686.25 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(6725.453125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(6786.734375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4e" transform="translate(6838.828125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(6896.734375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(6928.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(6980.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(7019.8125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(7081.09375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(7120.296875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(7183.671875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(7235.765625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(7297.296875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(7349.390625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(7381.171875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(7442.453125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(7481.359375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(7542.890625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(7574.671875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(7602.453125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(7665.828125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-45" transform="translate(7697.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(7761.09375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(7822.625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-46" transform="translate(7886 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4b" transform="translate(7940.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-50" transform="translate(8004.359375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-44" transform="translate(8101.765625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(8163.046875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4e" transform="translate(8204.15625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(8262.0625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-12" transform="translate(8314.15625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(8347.84375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(8386.75 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(8448.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(8500.375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4f" transform="translate(8563.75 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(8591.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(8630.734375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-12" transform="translate(8682.828125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-53" transform="translate(8716.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(8780 0)"/>
|
||||
<use xlink:href="#DejaVuSans-45" transform="translate(8843.375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4f" transform="translate(8906.859375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(8934.640625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(8962.421875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4b" transform="translate(9014.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(9077.890625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-47" transform="translate(9139.421875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-12" transform="translate(9202.90625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-11" transform="translate(9236.59375 0)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_16">
|
||||
@@ -2508,8 +2475,8 @@ z
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="p901540caf8">
|
||||
<rect x="141.004928" y="44.677428" width="327.986228" height="243.572795"/>
|
||||
<clipPath id="p09509dacb4">
|
||||
<rect x="141.004928" y="44.677428" width="441.282165" height="243.572795"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -4,6 +4,19 @@
|
||||
own design spec and implementation plan before code · **Baseline:** v1.5.1
|
||||
(154 registered tools, 19 groups, 1369 tests)
|
||||
|
||||
> **Status at release (1.6.0, 2026-09-25).** Tracks A, E, B, G, F and H shipped:
|
||||
> 247 registered tools in 28 groups. Criteria 1–4, 6 and 8 are met. 1 and 3 are
|
||||
> the `pid_roundtrip` and `mech_assembly` benchmark tasks; 2 is the foreign-P&ID
|
||||
> fixture test; 4 is the three topology focuses plus the correctness check
|
||||
> `diff_detects_known_edits`, on synthetic revisions rather than the README hero
|
||||
> sheet; 6 is `ab-v1.5.1-vs-v1.6.0.json` (48 / 48, 0 regressions); 8 is the
|
||||
> per-category rubric in `benchmarks/source_review.json` (93). **Criterion 5 is
|
||||
> withdrawn**: track D was dropped because the maintainer does not use Plant 3D.
|
||||
> **Criterion 7 is not met**: creation is 1.25× and 1.38× v1.4.0's time
|
||||
> (`perf-v1.4.0-vs-v1.6.0.json`), still 1.17× and 1.20× with the timeout off, and
|
||||
> it moves to 1.7. §4's rule that 1.6.0 waits for every criterion was set aside
|
||||
> for these two by the maintainer's decision to release.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
An LLM driving this server can — without guessing coordinates, to a named
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# v1.6 Track H — understanding foreign drawings, takeoffs, diff and topology
|
||||
|
||||
**Date:** 2026-09-24 · **Status:** design approved in conversation ·
|
||||
**Date:** 2026-09-24 · **Status:** shipped (v1.6.0-dev, track H) ·
|
||||
**Parent:** `2026-09-15-v1.6-roadmap-design.md` (track H; track D stays separate
|
||||
and follows) · **Baseline:** main after track F (`0abbe55`, 241 tools)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_comment": "GENERATED FILE - do not edit by hand. Derived from the live FastMCP component registry; regenerate with `python scripts/generate_tool_inventory.py`.",
|
||||
"schema_version": 1,
|
||||
"package_version": "1.5.1",
|
||||
"package_version": "1.6.0",
|
||||
"totals": {
|
||||
"tools": 247,
|
||||
"resources": 8,
|
||||
|
||||
@@ -11,6 +11,9 @@ class TableLayout:
|
||||
column_widths: list[float]
|
||||
row_height: float
|
||||
text_height: float
|
||||
#: The first row is a title, merged across every column as AutoCAD's
|
||||
#: TABLE draws it.
|
||||
title_row: bool = False
|
||||
|
||||
@property
|
||||
def row_count(self) -> int:
|
||||
@@ -71,7 +74,7 @@ def prepare_table_layout(
|
||||
if any(width <= 0 for width in widths):
|
||||
raise RuntimeError("entity_create_table: column widths must be positive")
|
||||
|
||||
return TableLayout(data, widths, float(row_height), float(text_height))
|
||||
return TableLayout(data, widths, float(row_height), float(text_height), title_row=bool(title))
|
||||
|
||||
|
||||
def validate_mleader(points: list[list[float]], text: str) -> list[tuple[float, float]]:
|
||||
|
||||
@@ -375,42 +375,55 @@ async def add_stair(backend, stair: dict, *, lang: str = "en", scale=50) -> dict
|
||||
async def draw_chains(
|
||||
backend, walls, openings, *, sides=("bottom", "left"), scale=50, first_offset=None, step=None
|
||||
) -> dict:
|
||||
"""The exterior chains as real linear DIMENSIONs on the dimension layer."""
|
||||
"""The exterior chains as real linear DIMENSIONs on the dimension layer.
|
||||
|
||||
Their text, arrows and gaps follow the plot scale like every other plan
|
||||
annotation: DIMSCALE is ``scale`` while the chains are drawn, so each chain
|
||||
dimension carries it, and the drawing's own DIMSCALE is put back afterwards.
|
||||
Drawn at the style's DIMSCALE 1, a 1:50 chain's 2.5 text was 2.5 drawing
|
||||
units - 0.05 mm on the sheet, which is no text at all.
|
||||
"""
|
||||
intents = exterior_chains(
|
||||
walls, openings, sides=sides, first_offset=first_offset, step=step, scale=scale
|
||||
)
|
||||
layer = ARCH_ROLE_LAYER["dim"]
|
||||
await ensure_arch_layers(backend, [layer])
|
||||
factor = float(scale)
|
||||
previous = await backend.system_get_variable("DIMSCALE")
|
||||
previous = 1.0 if previous is None else float(previous)
|
||||
handles: list[str] = []
|
||||
rows: list[dict] = []
|
||||
for intent in intents:
|
||||
horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0])
|
||||
info = await backend.dimension_linear(
|
||||
intent.p1[0],
|
||||
intent.p1[1],
|
||||
intent.p2[0],
|
||||
intent.p2[1],
|
||||
intent.text_at[0],
|
||||
intent.text_at[1],
|
||||
0.0 if horizontal else 90.0,
|
||||
layer,
|
||||
)
|
||||
handles.append(info.handle)
|
||||
rows.append(
|
||||
{
|
||||
"row": intent.feature,
|
||||
"from": list(intent.p1),
|
||||
"to": list(intent.p2),
|
||||
"value": math.dist(intent.p1, intent.p2),
|
||||
}
|
||||
)
|
||||
if abs(previous - factor) > 1e-9:
|
||||
await backend.system_set_variable("DIMSCALE", factor)
|
||||
try:
|
||||
for intent in intents:
|
||||
horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0])
|
||||
info = await backend.dimension_linear(
|
||||
intent.p1[0],
|
||||
intent.p1[1],
|
||||
intent.p2[0],
|
||||
intent.p2[1],
|
||||
intent.text_at[0],
|
||||
intent.text_at[1],
|
||||
0.0 if horizontal else 90.0,
|
||||
layer,
|
||||
)
|
||||
handles.append(info.handle)
|
||||
rows.append(
|
||||
{
|
||||
"row": intent.feature,
|
||||
"from": list(intent.p1),
|
||||
"to": list(intent.p2),
|
||||
"value": math.dist(intent.p1, intent.p2),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
if abs(previous - factor) > 1e-9:
|
||||
await backend.system_set_variable("DIMSCALE", previous)
|
||||
return {
|
||||
"count": len(handles),
|
||||
"handles": handles,
|
||||
"dimensions": rows,
|
||||
"text_height": (
|
||||
"set by the current dimension style; at 1:{0:g} set DIMSCALE={0:g} with "
|
||||
"dimstyle_modify so the text plots at its paper height".format(float(scale))
|
||||
),
|
||||
"dimscale": factor,
|
||||
"backend": backend.name,
|
||||
}
|
||||
|
||||
@@ -34,12 +34,10 @@ from engineering.layers import PID_LAYERS
|
||||
from engineering.pid.lines import PID_LINE_LAYERS
|
||||
from engineering.plan_spec import Issue
|
||||
from engineering.understand.topology import (
|
||||
NETWORK_CONFIDENCE,
|
||||
NETWORK_DISCIPLINES,
|
||||
network_layers,
|
||||
topology_findings,
|
||||
)
|
||||
from engineering.understand.vocab import classify_layer
|
||||
from engineering.understand.vocab import is_network_layer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import AutoCADBackend
|
||||
@@ -66,13 +64,7 @@ SEMANTIC_LAYERS: frozenset[str] = frozenset(
|
||||
|
||||
|
||||
def _is_candidate(name: str) -> bool:
|
||||
if name in SEMANTIC_LAYERS:
|
||||
return False
|
||||
meaning = classify_layer(name)
|
||||
return (
|
||||
meaning.get("discipline") in NETWORK_DISCIPLINES
|
||||
and float(meaning.get("confidence") or 0.0) >= NETWORK_CONFIDENCE
|
||||
)
|
||||
return name not in SEMANTIC_LAYERS and is_network_layer(name)
|
||||
|
||||
|
||||
def focus_layers(snap) -> list[str]:
|
||||
|
||||
@@ -32,10 +32,10 @@ from collections.abc import Sequence
|
||||
from engineering.arch.faces import Face, face_containing, planar_faces
|
||||
from engineering.pid.graph import ATTRIBUTE_TAGS, KEYWORDS
|
||||
from engineering.understand.clusters import find_clusters
|
||||
from engineering.understand.labels import parse_tag
|
||||
from engineering.understand.labels import parse_tag, wiring_target
|
||||
from engineering.understand.snapshot import EntityRecord, Snapshot, length_of
|
||||
from engineering.understand.units import infer_units, robust_extents, wall_segments
|
||||
from engineering.understand.vocab import classify_layer, room_label
|
||||
from engineering.understand.vocab import classify_layer, equipment_kind, room_label
|
||||
|
||||
__all__ = [
|
||||
"describe",
|
||||
@@ -52,6 +52,11 @@ ATTRIBUTE_TAG_CONFIDENCE = 0.95
|
||||
TEXT_TAG_CONFIDENCE = 0.8
|
||||
ROOM_FACE_CONFIDENCE = 0.9
|
||||
ROOM_LABEL_CONFIDENCE = 0.6
|
||||
#: A room read from a closed frame around its label: weaker than wall faces.
|
||||
ROOM_FRAME_CONFIDENCE = 0.8
|
||||
#: A frame is a room only when each side is at least this many label text
|
||||
#: heights long: a box drawn tight around the label is no room.
|
||||
FRAME_MIN_TEXT_HEIGHTS = 10.0
|
||||
TITLE_NAME_CONFIDENCE = 0.9
|
||||
TITLE_ATTRIBUTE_CONFIDENCE = 0.6
|
||||
#: The face finder pairs every wall segment with every other; above this many
|
||||
@@ -113,6 +118,9 @@ def tag_occurrences(snap: Snapshot, *, prep: dict | None = None) -> list[dict]:
|
||||
|
||||
A tag is read with `labels.parse_tag` from a TEXT / MTEXT / MULTILEADER,
|
||||
or from an INSERT attribute named like a tag (`pid.graph.ATTRIBUTE_TAGS`).
|
||||
A wiring callout (`labels.wiring_target`: ``Wiring to CP-2``) is not an
|
||||
occurrence of the panel it names - it stands at the load, not the panel -
|
||||
the rule `network.tag_occurrences` keeps too.
|
||||
Two readings of one tag in one space closer than
|
||||
:data:`TAG_MERGE_FRACTION` of the diagonal are one occurrence (the
|
||||
attribute's position and confidence win). Rows carry ``tag``, ``handle``,
|
||||
@@ -128,6 +136,8 @@ def tag_occurrences(snap: Snapshot, *, prep: dict | None = None) -> list[dict]:
|
||||
continue
|
||||
readings = []
|
||||
if rec.type in _TEXT_TYPES and rec.text:
|
||||
if wiring_target(rec.text):
|
||||
continue # names the panel it points at; it is not written there
|
||||
readings.append((parse_tag(rec.text), "text", TEXT_TAG_CONFIDENCE))
|
||||
elif rec.type == "INSERT":
|
||||
for name, value in rec.attribs:
|
||||
@@ -173,14 +183,22 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
|
||||
A label is any text `vocab.room_label` recognises. Wall layers are the
|
||||
ones `vocab.classify_layer` files under ``architecture``; their straight
|
||||
segments go through `engineering.arch.faces.planar_faces` (arcs are left
|
||||
out and counted). ``labelled`` pairs each room row with its `Face` for a
|
||||
caller that needs the geometry; ``rooms`` is JSON-ready.
|
||||
out and counted). A label no wall face holds takes the smallest closed,
|
||||
straight-sided polyline around it that holds no other room label and is at
|
||||
least ``FRAME_MIN_TEXT_HEIGHTS`` label heights a side - how a P&ID draws
|
||||
its rooms - at ``ROOM_FRAME_CONFIDENCE``. ``labelled`` pairs each room row
|
||||
with its `Face` for a caller that needs the geometry; ``rooms`` is
|
||||
JSON-ready.
|
||||
"""
|
||||
labels = []
|
||||
for rec in records:
|
||||
if rec.type in _TEXT_TYPES and rec.text:
|
||||
parsed = room_label(rec.text)
|
||||
at = _anchor(rec)
|
||||
# an unnumbered label that names equipment ('control cabinet - filling
|
||||
# room') labels the equipment; a numbered 'pump room 3' is a room
|
||||
if parsed and parsed["number"] is None and equipment_kind(rec.text):
|
||||
continue
|
||||
if parsed and at is not None:
|
||||
labels.append((rec, parsed, at))
|
||||
segments, bulged = wall_segments(records)
|
||||
@@ -196,8 +214,12 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
|
||||
rooms: list[dict] = []
|
||||
labelled: list[tuple[dict, Face]] = []
|
||||
used: set[int] = set()
|
||||
frames = _frames(records)
|
||||
for rec, parsed, at in labels:
|
||||
face = face_containing(faces, at) if faces else None
|
||||
from_frame = None
|
||||
if face is None and frames:
|
||||
from_frame = _frame_around(rec, parsed, at, frames, labels)
|
||||
row = {
|
||||
"number": parsed.get("number"),
|
||||
"name": parsed.get("name"),
|
||||
@@ -212,14 +234,16 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
|
||||
}
|
||||
if face is not None:
|
||||
used.add(id(face))
|
||||
chosen = face or from_frame
|
||||
if chosen is not None:
|
||||
row["face"] = {
|
||||
"area": face.area,
|
||||
"polygon": [[p[0], p[1]] for p in face.loop],
|
||||
"centroid": [face.centroid[0], face.centroid[1]],
|
||||
"area": chosen.area,
|
||||
"polygon": [[p[0], p[1]] for p in chosen.loop],
|
||||
"centroid": [chosen.centroid[0], chosen.centroid[1]],
|
||||
}
|
||||
row["source"] = "label+face"
|
||||
row["confidence"] = ROOM_FACE_CONFIDENCE
|
||||
labelled.append((row, face))
|
||||
row["source"] = "label+face" if face is not None else "label+frame"
|
||||
row["confidence"] = ROOM_FACE_CONFIDENCE if face is not None else ROOM_FRAME_CONFIDENCE
|
||||
labelled.append((row, chosen))
|
||||
rooms.append(row)
|
||||
rooms.sort(key=lambda r: (r["cluster"] or "", r["number"] or "", r["name"] or "", r["at"]))
|
||||
return {
|
||||
@@ -233,6 +257,74 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
|
||||
}
|
||||
|
||||
|
||||
def _frames(records: Sequence[EntityRecord]) -> list[tuple[EntityRecord, Face]]:
|
||||
"""Closed, straight-sided polylines as faces: the frames a room may be drawn as."""
|
||||
out = []
|
||||
for rec in records:
|
||||
if rec.type not in ("LWPOLYLINE", "POLYLINE") or not rec.closed or len(rec.points) < 3:
|
||||
continue
|
||||
if any(rec.bulges):
|
||||
continue
|
||||
face = _polygon_face(rec.points)
|
||||
if face is not None:
|
||||
out.append((rec, face))
|
||||
return out
|
||||
|
||||
|
||||
def _polygon_face(points) -> Face | None:
|
||||
pts = [(float(x), float(y)) for x, y in points]
|
||||
signed = 0.0
|
||||
cx = cy = 0.0
|
||||
for (x0, y0), (x1, y1) in zip(pts, pts[1:] + pts[:1], strict=True):
|
||||
cross = x0 * y1 - x1 * y0
|
||||
signed += cross
|
||||
cx += (x0 + x1) * cross
|
||||
cy += (y0 + y1) * cross
|
||||
signed /= 2.0
|
||||
if abs(signed) <= 0.0:
|
||||
return None
|
||||
centroid = (cx / (6.0 * signed), cy / (6.0 * signed))
|
||||
if signed < 0.0:
|
||||
pts.reverse()
|
||||
start = min(range(len(pts)), key=lambda i: (pts[i][1], pts[i][0]))
|
||||
loop = tuple(pts[start:] + pts[:start])
|
||||
return Face(loop=loop, area=abs(signed), centroid=centroid)
|
||||
|
||||
|
||||
def _frame_around(rec, parsed, at, frames, labels) -> Face | None:
|
||||
"""The smallest frame around ``at`` that is a room: no label of another room
|
||||
number in it (an unnumbered mention - 'hot room' - does not count), each side
|
||||
at least FRAME_MIN_TEXT_HEIGHTS label heights."""
|
||||
least = FRAME_MIN_TEXT_HEIGHTS * float(rec.height or 0.0)
|
||||
best = None
|
||||
for frame_rec, face in frames:
|
||||
box = frame_rec.bbox or (
|
||||
min(p[0] for p in face.loop),
|
||||
min(p[1] for p in face.loop),
|
||||
max(p[0] for p in face.loop),
|
||||
max(p[1] for p in face.loop),
|
||||
)
|
||||
if not (box[0] <= at[0] <= box[2] and box[1] <= at[1] <= box[3]):
|
||||
continue
|
||||
if min(box[2] - box[0], box[3] - box[1]) < least:
|
||||
continue
|
||||
if face_containing([face], at) is None:
|
||||
continue
|
||||
others = [
|
||||
other_at
|
||||
for other, other_parsed, other_at in labels
|
||||
if other is not rec
|
||||
and other_parsed["number"]
|
||||
and other_parsed["number"] != parsed["number"]
|
||||
and face_containing([face], other_at) is not None
|
||||
]
|
||||
if others:
|
||||
continue
|
||||
if best is None or face.area < best.area:
|
||||
best = face
|
||||
return best
|
||||
|
||||
|
||||
def drawing_units(snap: Snapshot, *, prep: dict | None = None, rooms: dict | None = None) -> dict:
|
||||
"""`units.infer_units` the one way every reader calls it: on the records kept
|
||||
after the outliers, with the labelled room faces as evidence.
|
||||
|
||||
@@ -16,6 +16,7 @@ from engineering.understand.network import build_network
|
||||
from engineering.understand.report import check_lang, write_workbook
|
||||
from engineering.understand.snapshot import take_snapshot
|
||||
from engineering.understand.takeoff import (
|
||||
PANEL_RULES,
|
||||
cable_rows,
|
||||
check_allowance,
|
||||
check_pipe_options,
|
||||
@@ -51,6 +52,7 @@ async def run_pipe_takeoff(
|
||||
force: bool = False,
|
||||
layers=None,
|
||||
tol: float | None = None,
|
||||
exclude_regions=None,
|
||||
) -> dict:
|
||||
"""`pipe_rows` over a P&ID and an optional layout, plus the workbook when `output` is named.
|
||||
|
||||
@@ -85,6 +87,7 @@ async def run_pipe_takeoff(
|
||||
vertical_allowance=options["vertical_allowance"],
|
||||
length_source=options["length_source"],
|
||||
force=bool(force),
|
||||
exclude_regions=tuple(exclude_regions or ()),
|
||||
)
|
||||
|
||||
result = await asyncio.to_thread(compute)
|
||||
@@ -105,15 +108,25 @@ async def run_cable_takeoff(
|
||||
lang: str = "en",
|
||||
allowance: float = 0.20,
|
||||
section_rules=None,
|
||||
panel_rule: str = "nearest",
|
||||
) -> dict:
|
||||
"""`cable_rows` over a P&ID and an optional layout, plus the workbook when `output` is named."""
|
||||
check_lang(lang)
|
||||
allowance = check_allowance(allowance)
|
||||
rules = check_section_rules(section_rules)
|
||||
if panel_rule not in PANEL_RULES:
|
||||
raise ValueError(
|
||||
f"panel_rule: {panel_rule!r} unknown; choose from {', '.join(PANEL_RULES)}"
|
||||
)
|
||||
pid = await take_snapshot(backend, pid_path)
|
||||
layout = await take_snapshot(backend, layout_path) if layout_path else None
|
||||
result = await asyncio.to_thread(
|
||||
cable_rows, pid, layout, allowance=allowance, section_rules=list(rules) or None
|
||||
cable_rows,
|
||||
pid,
|
||||
layout,
|
||||
allowance=allowance,
|
||||
section_rules=list(rules) or None,
|
||||
panel_rule=panel_rule,
|
||||
)
|
||||
result["files"] = (
|
||||
await asyncio.to_thread(write_workbook, result, kind="cable", lang=lang, path=output)
|
||||
|
||||
@@ -32,6 +32,7 @@ import re
|
||||
__all__ = [
|
||||
"CYRILLIC_LOOKALIKES",
|
||||
"fold_lookalikes",
|
||||
"is_panel_name",
|
||||
"normalize_panel",
|
||||
"parse_diameters",
|
||||
"parse_electrical",
|
||||
@@ -65,6 +66,10 @@ _TOGGLE_CODES = frozenset("LlOoKkN")
|
||||
|
||||
_UNICODE_ESCAPE = re.compile(r"\\[Uu]\+([0-9A-Fa-f]{4})")
|
||||
_PERCENT_CODE = re.compile(r"%%(\d{3}|[cdpCDP]|[uoUOkK])")
|
||||
#: Caret notation, how a TEXT or ATTRIB stores a control character: ^J a line
|
||||
#: break, ^M a carriage return (read as a break), ^I a tab, and "^ " a caret.
|
||||
_CARET = re.compile(r"\^([JMI ])")
|
||||
_CARET_CHARS = {"J": "\n", "M": "\n", "I": "\t", " ": "^"}
|
||||
|
||||
#: Every spelling of the diameter sign met in drawings: Ø (U+00D8), ø (U+00F8),
|
||||
#: ∅ (U+2205, AutoCAD's listed diameter symbol), ⌀ (U+2300,
|
||||
@@ -131,6 +136,15 @@ _WIRING_PHRASES = (
|
||||
)
|
||||
#: Turkish puts the panel first: ``CP1 panosuna`` ("to the CP1 panel").
|
||||
_WIRING_PHRASES_AFTER = ("panosuna", "panosundan")
|
||||
#: The bare Russian preposition к / ко ("to") with the panel as the next word,
|
||||
#: optionally after "щиту" / "шкафу" / "панели" (switchboard / cabinet / panel):
|
||||
#: a Russian P&ID closes a load's text with ``к CP1``. Only a whole word counts,
|
||||
#: so the к of кВт is no preposition.
|
||||
_RU_TO_PANEL = re.compile(
|
||||
r"(?:^|\s)ко?\s+"
|
||||
r"(?:(?:щиту|шкафу|панели)\s+)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def plain(text: str | None) -> str:
|
||||
@@ -138,8 +152,9 @@ def plain(text: str | None) -> str:
|
||||
|
||||
``\\U+XXXX`` becomes its character; ``%%c`` / ``%%d`` / ``%%p`` become Ø / ° /
|
||||
±, ``%%nnn`` the character with that code, ``%%%`` a percent sign, and the
|
||||
``%%u`` / ``%%o`` / ``%%k`` toggles disappear. In MTEXT, ``\\P`` is a line
|
||||
break, ``\\~`` a space, ``\\S a^b;`` / ``\\S a/b;`` the stacked ``a/b``,
|
||||
``%%u`` / ``%%o`` / ``%%k`` toggles disappear, and the caret notation of a
|
||||
TEXT (``^J`` a line break, ``^I`` a tab, ``^ `` a caret) is decoded. In
|
||||
MTEXT, ``\\P`` is a line break, ``\\~`` a space, ``\\S a^b;`` / ``\\S a/b;`` the stacked ``a/b``,
|
||||
``\\\\`` ``\\{`` ``\\}`` the literal characters; every argument code
|
||||
(``\\f...;``, ``\\H...;``, ``\\C...;`` ...) and every toggle is dropped, and so
|
||||
are the grouping braces.
|
||||
@@ -147,6 +162,7 @@ def plain(text: str | None) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
source = _UNICODE_ESCAPE.sub(lambda m: chr(int(m.group(1), 16)), str(text))
|
||||
source = _CARET.sub(lambda m: _CARET_CHARS[m.group(1)], source)
|
||||
source = source.replace("%%%", "\x00")
|
||||
|
||||
def percent(match: re.Match) -> str:
|
||||
@@ -284,12 +300,29 @@ def parse_electrical(text: str | None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
#: Letter prefixes that name what a load is wired to: control panel, junction
|
||||
#: box, distribution board, motor control centre, local / motor control panel,
|
||||
#: power panel, switchboard. A convention list, not a standard.
|
||||
PANEL_PREFIXES = ("CP", "JB", "DB", "MCC", "LCP", "MCP", "PP", "SB", "SWB")
|
||||
|
||||
|
||||
def is_panel_name(name: str | None) -> bool:
|
||||
"""A normalised name whose letters say it is a panel: ``CP-1``, ``JB-2``,
|
||||
``CP-M82``, ``MCC-3`` - not ``M-87``, which `normalize_panel` would also
|
||||
shape, because any letters-and-digits token has a panel's form."""
|
||||
if not name:
|
||||
return False
|
||||
return str(name).split("-", 1)[0] in PANEL_PREFIXES
|
||||
|
||||
|
||||
def wiring_target(text: str | None) -> str | None:
|
||||
"""The panel a wiring callout points to, normalised.
|
||||
|
||||
``"Wiring to CP1"`` -> ``"CP-1"``; ``"подключение к шкафу CP-1"`` ->
|
||||
``"CP-1"``; ``"CP 2 panosuna"`` -> ``"CP-2"`` (Turkish names the panel
|
||||
first). None when the text has no wiring phrase or no panel beside it.
|
||||
first); ``"... кВт\\nк CP1"`` -> ``"CP-1"`` (the bare Russian "to", the
|
||||
panel as the next word). None when the text has no wiring phrase or no
|
||||
panel beside it.
|
||||
"""
|
||||
body = plain(text)
|
||||
lowered = body.lower()
|
||||
@@ -305,4 +338,8 @@ def wiring_target(text: str | None) -> str | None:
|
||||
matches = list(_PANEL_RE.finditer(fold_lookalikes(body[:at])))
|
||||
if matches:
|
||||
return f"{matches[-1].group(1)}-{matches[-1].group(2)}"
|
||||
for to in _RU_TO_PANEL.finditer(body):
|
||||
panel = _PANEL_RE.match(fold_lookalikes(body[to.end() :]))
|
||||
if panel is not None:
|
||||
return f"{panel.group(1)}-{panel.group(2)}"
|
||||
return None
|
||||
|
||||
@@ -72,7 +72,7 @@ from dataclasses import dataclass
|
||||
|
||||
from engineering.understand.labels import parse_diameters, parse_tag, wiring_target
|
||||
from engineering.understand.snapshot import EntityRecord, Pt, Snapshot
|
||||
from engineering.understand.vocab import classify_layer, fold, supply_return
|
||||
from engineering.understand.vocab import classify_layer, fold, is_network_layer, supply_return
|
||||
|
||||
__all__ = [
|
||||
"EQUIPMENT_SEARCH_SHARE",
|
||||
@@ -262,13 +262,10 @@ def _layer_names(snap: Snapshot) -> list[str]:
|
||||
|
||||
|
||||
def default_layers(snap: Snapshot) -> tuple[str, ...]:
|
||||
"""Every layer `classify_layer` files under piping or electrical with confidence >= 0.9."""
|
||||
chosen = []
|
||||
for name in _layer_names(snap):
|
||||
found = classify_layer(name)
|
||||
if found["discipline"] in NETWORK_DISCIPLINES and found["confidence"] >= NETWORK_CONFIDENCE:
|
||||
chosen.append(name)
|
||||
return tuple(chosen)
|
||||
"""Every layer of pipes or cables: `vocab.is_network_layer` - piping or electrical
|
||||
at confidence >= 0.9, and not a layer of valve, pump or tank symbols or of
|
||||
annotation (sizes, materials, numbers), whose lines are no pipe."""
|
||||
return tuple(name for name in _layer_names(snap) if is_network_layer(name))
|
||||
|
||||
|
||||
def label_search_default(snap: Snapshot, *, tol: float = 1.0) -> float:
|
||||
@@ -413,8 +410,9 @@ def _choose_layers(snap: Snapshot, layers) -> tuple[str, ...]:
|
||||
chosen = default_layers(snap)
|
||||
if not chosen:
|
||||
raise ValueError(
|
||||
"layers: no layer is classified piping or electrical with confidence >= 0.9; "
|
||||
f"name the layers to read. The drawing has: {', '.join(known)}"
|
||||
"layers: no layer is classified piping or electrical with confidence >= 0.9 "
|
||||
"(symbol and annotation layers left out); name the layers to read. The drawing "
|
||||
f"has: {', '.join(known)}"
|
||||
)
|
||||
return chosen
|
||||
names = [str(name) for name in layers]
|
||||
|
||||
@@ -80,6 +80,12 @@ LABELS: dict[str, tuple[str, str, str]] = {
|
||||
# Kontrol kinds and reasons
|
||||
"unassigned_diameter": ("Çap atanmamış", "Diameter unassigned", "Диаметр не назначен"),
|
||||
"unroutable": ("Yönlendirilemez", "Unroutable", "Трассировка невозможна"),
|
||||
"excluded_region": (
|
||||
"Detay kopyası (hariç)",
|
||||
"Detail copy (left out)",
|
||||
"Копия детали (исключена)",
|
||||
),
|
||||
"detail_copies": ("Olası detay kopyaları", "Possible detail copies", "Возможные копии деталей"),
|
||||
"end_not_on_equipment": (
|
||||
"Bir uç ekipmanda değil",
|
||||
"An end is on no equipment",
|
||||
@@ -192,6 +198,7 @@ LABELS: dict[str, tuple[str, str, str]] = {
|
||||
"rule_rounding": ("Kural: yuvarlama", "Rule: rounding", "Правило: округление"),
|
||||
"rule_power": ("Kural: güç", "Rule: power", "Правило: мощность"),
|
||||
"rule_panel": ("Kural: pano", "Rule: panel", "Правило: шкаф"),
|
||||
"panel_inferred": ("Pano tahmini (en yakın)", "Panel inferred (nearest)", "Шкаф по близости"),
|
||||
"rule_package": ("Kural: pano paketleri", "Rule: panel packages", "Правило: комплектные шкафы"),
|
||||
"rule_section": ("Kural: kesit", "Rule: section", "Правило: сечение"),
|
||||
"section_rules": (
|
||||
@@ -315,6 +322,17 @@ TEXTS: dict[str, tuple[str, str, str]] = {
|
||||
"Шкаф берётся из выносок подключения на планировке ('Wiring to CP1' и варианты), иначе "
|
||||
"из P&ID; имена нормализуются (CP 1 → CP-1).",
|
||||
),
|
||||
"rule_panel_nearest": (
|
||||
"Pano, yerleşimdeki bağlantı notlarından, yoksa P&ID'den alınır; hiçbiri yoksa ve yükün "
|
||||
"etiketi yerleşimde ise yerleşimdeki en yakın pano seçilir ve satır Kontrol sayfasında "
|
||||
"'pano tahmini' olarak işaretlenir. Adlar normalleştirilir (CP 1 → CP-1).",
|
||||
"The panel comes from the layout's wiring callouts, else from the P&ID; when neither "
|
||||
"names one and the load's tag is on the layout, the nearest panel there is taken and the "
|
||||
"row is flagged 'panel inferred' on the control sheet. Names are normalised (CP 1 → CP-1).",
|
||||
"Шкаф берётся из выносок подключения на планировке, иначе из P&ID; если нет ни того, ни "
|
||||
"другого, а тег нагрузки есть на планировке, берётся ближайший шкаф, и строка отмечается "
|
||||
"на листе контроля как 'шкаф по близости'. Имена нормализуются (CP 1 → CP-1).",
|
||||
),
|
||||
"rule_package": (
|
||||
"Pano paketleri: kendisi güç belirten bir panoya bağlı yük o paketin parçasıdır; mahal ve "
|
||||
"pano toplamları paketin gücünü sayar, alt yüklerini saymaz - hiçbir güç iki kez "
|
||||
@@ -526,6 +544,15 @@ def _pipe_method(result: dict, lang: str) -> list[list]:
|
||||
["tol", method["tol"]],
|
||||
["label_search", round(float(method["label_search"]), 6)],
|
||||
["overlap", _num(method["overlap_length_m"])],
|
||||
[
|
||||
"detail_copies",
|
||||
"; ".join(
|
||||
f"{region['id']} ({', '.join(region['tags'])})"
|
||||
+ (" - " + _t("excluded_region", lang) if region["excluded"] else "")
|
||||
for region in result.get("detail_copies", [])
|
||||
)
|
||||
or "—",
|
||||
],
|
||||
]
|
||||
rows += [["warning", warning] for warning in result["warnings"]]
|
||||
return [[_t(topic, lang), value] for topic, value in rows]
|
||||
@@ -615,7 +642,13 @@ def _cable_method(result: dict, lang: str) -> list[list]:
|
||||
["rule_allowance", method["allowance"]],
|
||||
["rule_rounding", _say("rule_rounding", lang)],
|
||||
["rule_power", _say("rule_power", lang)],
|
||||
["rule_panel", _say("rule_panel", lang)],
|
||||
[
|
||||
"rule_panel",
|
||||
_say(
|
||||
"rule_panel_nearest" if method.get("panel_rule") == "nearest" else "rule_panel",
|
||||
lang,
|
||||
),
|
||||
],
|
||||
["rule_package", _say("rule_package", lang)],
|
||||
["rule_section", _say("rule_section", lang)],
|
||||
["section_rules", "; ".join(_rule_text(rule) for rule in rules) or "—"],
|
||||
|
||||
@@ -27,11 +27,17 @@ drawing.
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
|
||||
from engineering.arch.faces import face_containing
|
||||
from engineering.understand.describe import FACE_TOL_FRACTION, drawing_units, find_rooms, prepare
|
||||
from engineering.understand.labels import normalize_panel, parse_electrical, wiring_target
|
||||
from engineering.understand.labels import (
|
||||
is_panel_name,
|
||||
normalize_panel,
|
||||
parse_electrical,
|
||||
wiring_target,
|
||||
)
|
||||
from engineering.understand.network import (
|
||||
MODEL,
|
||||
TEXT_TYPES,
|
||||
@@ -226,9 +232,13 @@ def room_regions(snap: Snapshot, cluster=None, *, prep: dict | None = None) -> d
|
||||
def key(row: dict) -> str:
|
||||
return str(row["number"] or row["name"] or row["text"])
|
||||
|
||||
# without a face a piece goes to the nearest label: when the drawing numbers
|
||||
# its rooms, an unnumbered mention ('to room storage') is a note, not a room
|
||||
numbered = [row for row in found["rooms"] if row["number"]]
|
||||
candidates = numbered or found["rooms"]
|
||||
return {
|
||||
"faces": [(key(row), face) for row, face in found["labelled"]],
|
||||
"labels": sorted((key(row), (row["at"][0], row["at"][1])) for row in found["rooms"]),
|
||||
"labels": sorted((key(row), (row["at"][0], row["at"][1])) for row in candidates),
|
||||
}
|
||||
|
||||
|
||||
@@ -322,6 +332,202 @@ def _choose_source(requested: str, layout, scale: dict, force: bool) -> tuple[st
|
||||
return "layout", f"auto_{verdict}"
|
||||
|
||||
|
||||
def _row(rows: dict, room: str, service: str, diameter: str) -> dict:
|
||||
return rows.setdefault(
|
||||
(room, service, diameter),
|
||||
{
|
||||
"room": room,
|
||||
"service": service,
|
||||
"diameter": diameter,
|
||||
"direct_m": 0.0,
|
||||
"continuity_m": 0.0,
|
||||
"unassigned_m": 0.0,
|
||||
"net_m": 0.0,
|
||||
"schematic_m": 0.0,
|
||||
"status": "A",
|
||||
"runs": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _pid_cells(edges, regions: dict, m_per_unit: float) -> dict:
|
||||
"""{(room, diameter): {net, direct, continuity, unassigned}} in metres, piece by piece.
|
||||
|
||||
With room faces a piece is cut at the outlines (`apportion` on its chord,
|
||||
scaled to the piece's true length, so a bend keeps its arc length); without
|
||||
faces the whole piece goes to the room label nearest its midpoint.
|
||||
"""
|
||||
cells: dict[tuple[str, str], dict] = {}
|
||||
for edge in edges:
|
||||
a, b = edge["a"], edge["b"]
|
||||
metres = edge["length"] * m_per_unit
|
||||
pieces = apportion([(a, b)], regions) if regions["faces"] else {}
|
||||
chord = sum(pieces.values())
|
||||
if chord > 0.0:
|
||||
shares = {room: value / chord for room, value in pieces.items()}
|
||||
else:
|
||||
shares = {room_at(regions, ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)): 1.0}
|
||||
diameter = edge["diameter"] or UNASSIGNED
|
||||
for room, share in shares.items():
|
||||
cell = cells.setdefault(
|
||||
(room, diameter), {"net": 0.0, "direct": 0.0, "continuity": 0.0, "unassigned": 0.0}
|
||||
)
|
||||
cell["net"] += metres * share
|
||||
cell[edge["diameter_source"]] += metres * share
|
||||
return cells
|
||||
|
||||
|
||||
#: How a load no callout places gets its panel: the nearest one on the layout
|
||||
#: (flagged ``panel_inferred``), or none (``missing_panel``).
|
||||
PANEL_RULES = ("nearest", "stated")
|
||||
|
||||
|
||||
def _nearest_panel(here: Pt, panel_at: dict, *, exclude=None) -> tuple[str, float] | None:
|
||||
"""(panel, distance in layout units) of the panel nearest ``here``; ties by name."""
|
||||
candidates = [(math.dist(here, p), name) for name, p in panel_at.items() if name != exclude]
|
||||
if not candidates:
|
||||
return None
|
||||
distance, name = min(candidates)
|
||||
return name, distance
|
||||
|
||||
|
||||
#: Detail copies: tags drawn twice are grouped when their occurrences lie within
|
||||
#: this share of the P&ID's robust diagonal of each other (single linkage) ...
|
||||
DETAIL_LINK_SHARE = 0.03
|
||||
#: ... and a group is a possible detail copy when it holds at least this many
|
||||
#: distinct repeated tags, each of which also stands somewhere outside it. The
|
||||
#: same count of an equipment's own parts (M150 -> M150B, M150C, M150D) standing
|
||||
#: within DETAIL_FAMILY_SHARE of the diagonal is a possible package or detail.
|
||||
DETAIL_MIN_TAGS = 3
|
||||
DETAIL_FAMILY_SHARE = 0.15
|
||||
_FAMILY_TAG = re.compile(r"^(?P<base>[A-Z]{1,4}-?\d{1,5})(?P<part>[A-Z]{1,2})$")
|
||||
|
||||
|
||||
def detail_copy_regions(pid: Snapshot, *, prep: dict | None = None) -> list[dict]:
|
||||
"""Where a P&ID may draw pipes twice: ``[{"id", "box", "tags", "kind", "base"}]``.
|
||||
|
||||
``repeated``: tags written more than once (readings of one tag closer than
|
||||
half the link are one) grouped by where they stand; a compact group of at
|
||||
least ``DETAIL_MIN_TAGS`` such tags, every one also written outside it, is a
|
||||
possible detail copy. ``family``: at least ``DETAIL_MIN_TAGS`` parts of one
|
||||
equipment (``M150B``, ``M150C`` ... of ``M150``) standing close together, a
|
||||
possible package or detail whose internal pipes a site takeoff may leave to
|
||||
its vendor. Ids run D1, D2 ... from the left. A heuristic that reports and
|
||||
never removes: the caller decides.
|
||||
"""
|
||||
prep = prep or prepare(pid)
|
||||
link = DETAIL_LINK_SHARE * prep["diag"]
|
||||
if link <= 0.0:
|
||||
return []
|
||||
occurrences = {
|
||||
tag: [place["at"] for place in places] for tag, places in tag_occurrences(pid).items()
|
||||
}
|
||||
points = [
|
||||
(tag, at)
|
||||
for tag, places in occurrences.items()
|
||||
for at in _distinct(places, link / 2.0)
|
||||
if len(_distinct(places, link / 2.0)) > 1
|
||||
]
|
||||
parent = list(range(len(points)))
|
||||
|
||||
def find(k: int) -> int:
|
||||
while parent[k] != k:
|
||||
parent[k] = parent[parent[k]]
|
||||
k = parent[k]
|
||||
return k
|
||||
|
||||
grid: dict[tuple[int, int], list[int]] = {}
|
||||
for k, (_tag, at) in enumerate(points):
|
||||
grid.setdefault((math.floor(at[0] / link), math.floor(at[1] / link)), []).append(k)
|
||||
for (cx, cy), members in grid.items():
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
for j in grid.get((cx + dx, cy + dy), ()):
|
||||
for i in members:
|
||||
if i < j and math.dist(points[i][1], points[j][1]) <= link:
|
||||
parent[find(i)] = find(j)
|
||||
groups: dict[int, list[int]] = {}
|
||||
for k in range(len(points)):
|
||||
groups.setdefault(find(k), []).append(k)
|
||||
total = {}
|
||||
for tag, _at in points:
|
||||
total[tag] = total.get(tag, 0) + 1
|
||||
regions = []
|
||||
for members in groups.values():
|
||||
tags = sorted({points[k][0] for k in members})
|
||||
if len(tags) < DETAIL_MIN_TAGS:
|
||||
continue
|
||||
here = {}
|
||||
for k in members:
|
||||
here[points[k][0]] = here.get(points[k][0], 0) + 1
|
||||
if any(here[tag] >= total[tag] for tag in tags):
|
||||
continue # a tag written only here: this is where it lives, not a copy
|
||||
xs = [points[k][1][0] for k in members]
|
||||
ys = [points[k][1][1] for k in members]
|
||||
regions.append(
|
||||
{
|
||||
"box": [min(xs) - link, min(ys) - link, max(xs) + link, max(ys) + link],
|
||||
"tags": tags,
|
||||
"kind": "repeated",
|
||||
"base": None,
|
||||
}
|
||||
)
|
||||
families: dict[str, dict[str, list]] = {}
|
||||
for tag, places in occurrences.items():
|
||||
match = _FAMILY_TAG.match(tag)
|
||||
if match:
|
||||
families.setdefault(match["base"], {})[tag] = places
|
||||
for base, parts in sorted(families.items()):
|
||||
if len(parts) < DETAIL_MIN_TAGS:
|
||||
continue
|
||||
xs = [p[0] for places in parts.values() for p in places]
|
||||
ys = [p[1] for places in parts.values() for p in places]
|
||||
if math.hypot(max(xs) - min(xs), max(ys) - min(ys)) > DETAIL_FAMILY_SHARE * prep["diag"]:
|
||||
continue
|
||||
regions.append(
|
||||
{
|
||||
"box": [min(xs) - link, min(ys) - link, max(xs) + link, max(ys) + link],
|
||||
"tags": sorted(parts),
|
||||
"kind": "family",
|
||||
"base": base,
|
||||
}
|
||||
)
|
||||
regions.sort(key=lambda region: (region["box"][0], region["box"][1]))
|
||||
return [{"id": f"D{i}", **region} for i, region in enumerate(regions, start=1)]
|
||||
|
||||
|
||||
def _distinct(places, radius: float) -> list:
|
||||
"""The places of one tag, readings closer than ``radius`` to a kept one dropped."""
|
||||
kept: list = []
|
||||
for at in places:
|
||||
if all(math.dist(at, other) > radius for other in kept):
|
||||
kept.append(at)
|
||||
return kept
|
||||
|
||||
|
||||
def _majority_region(edges, boxes: dict) -> str | None:
|
||||
"""The region holding more than half a run's drawn length, by piece midpoints."""
|
||||
total = sum(edge["length"] for edge in edges)
|
||||
if not boxes or total <= 0.0:
|
||||
return None
|
||||
share: dict[str, float] = {}
|
||||
for edge in edges:
|
||||
mid = ((edge["a"][0] + edge["b"][0]) / 2.0, (edge["a"][1] + edge["b"][1]) / 2.0)
|
||||
for region_id, box in boxes.items():
|
||||
if box[0] <= mid[0] <= box[2] and box[1] <= mid[1] <= box[3]:
|
||||
share[region_id] = share.get(region_id, 0.0) + edge["length"]
|
||||
break
|
||||
best = max(share.items(), key=lambda item: item[1], default=None)
|
||||
return best[0] if best is not None and best[1] > total / 2.0 else None
|
||||
|
||||
|
||||
def _reasons(flagged: list[dict]) -> str:
|
||||
counts: dict[str, int] = {}
|
||||
for run in flagged:
|
||||
counts[run["reason"]] = counts.get(run["reason"], 0) + 1
|
||||
return ", ".join(f"{reason} {n}" for reason, n in sorted(counts.items()))
|
||||
|
||||
|
||||
def pipe_rows(
|
||||
network,
|
||||
pid: Snapshot,
|
||||
@@ -333,6 +539,7 @@ def pipe_rows(
|
||||
vertical_allowance=0.0,
|
||||
length_source="auto",
|
||||
force=False,
|
||||
exclude_regions=(),
|
||||
) -> dict:
|
||||
"""Takeoff rows by room x service x diameter, with the runs, checks and method behind them.
|
||||
|
||||
@@ -343,13 +550,26 @@ def pipe_rows(
|
||||
`length_source='pid'` on a P&ID the scale check calls schematic unless
|
||||
`force=True` (the refusal quotes the statistics).
|
||||
|
||||
Status per run: **A** every end on a tag placed on the layout and every
|
||||
piece's diameter read directly; **B** a diameter carried by continuity or
|
||||
unassigned; **C** not routable - an end on no equipment, fewer than two
|
||||
tags, a tag missing from (or written twice on) the layout. A C run goes to
|
||||
`control` with its schematic length only, is counted in the totals and is
|
||||
flagged. A run holding several diameters shares its length among them in
|
||||
Status per run: **A** every piece's diameter read directly; **B** a
|
||||
diameter carried by continuity or unassigned; **C**, on the layout only,
|
||||
not routable - an end on no equipment, fewer than two tags, a tag missing
|
||||
from (or written twice on) the layout. A C run goes to `control` with its
|
||||
schematic length only, is counted in the totals and is flagged, and a
|
||||
warning says how much could not be routed. Measured on the P&ID a run needs
|
||||
no equipment at its ends - the drawn geometry is the length - and each
|
||||
piece goes to the P&ID room it lies in (cut at the room outlines when the
|
||||
P&ID has room faces, else the nearest room label). On the layout a run
|
||||
holding several diameters shares its routed length among them in
|
||||
proportion to their drawn length on the P&ID.
|
||||
|
||||
Detail copies: where several tags the P&ID draws twice sit close together
|
||||
(`detail_copy_regions`), a detail of equipment may repeat its pipes. Each
|
||||
such region is reported in ``detail_copies`` and counted like any other
|
||||
drawing; a run with more than half its drawn length in a region named in
|
||||
``exclude_regions`` is left out, listed in ``control`` as
|
||||
``excluded_region`` and totalled in ``excluded_m``. An unknown region id is
|
||||
refused with the ids the P&ID has. Which copy is the detail is the
|
||||
engineer's call: the drawing does not say.
|
||||
"""
|
||||
options = check_pipe_options(
|
||||
services=services,
|
||||
@@ -382,13 +602,45 @@ def pipe_rows(
|
||||
)
|
||||
m_pid = pid_unit["m_per_unit"]
|
||||
m_layout = layout_unit["m_per_unit"] if layout_unit else None
|
||||
copies = detail_copy_regions(pid, prep=prep_pid)
|
||||
known_ids = [region["id"] for region in copies]
|
||||
left_out = [str(region_id) for region_id in (exclude_regions or ())]
|
||||
for region_id in left_out:
|
||||
if region_id not in known_ids:
|
||||
raise ValueError(
|
||||
f"exclude_regions: {region_id!r} unknown; the P&ID has "
|
||||
f"{', '.join(known_ids) or 'no detail copy'}"
|
||||
)
|
||||
boxes = {region["id"]: region["box"] for region in copies}
|
||||
for region in copies:
|
||||
region["pipe_m"] = 0.0
|
||||
region["excluded"] = region["id"] in left_out
|
||||
|
||||
rows: dict[tuple[str, str, str], dict] = {}
|
||||
runs_out, control, excluded = [], [], {}
|
||||
excluded_m = 0.0
|
||||
for run in network["runs"]:
|
||||
if run.service not in options["services"]:
|
||||
excluded[run.service] = excluded.get(run.service, 0) + 1
|
||||
continue
|
||||
inside = _majority_region(run.edges, boxes)
|
||||
if inside is not None:
|
||||
region = next(r for r in copies if r["id"] == inside)
|
||||
region["pipe_m"] += sum(edge["length"] for edge in run.edges) * m_pid
|
||||
if inside in left_out:
|
||||
drawn_m = sum(edge["length"] for edge in run.edges) * m_pid
|
||||
excluded_m += drawn_m
|
||||
control.append(
|
||||
{
|
||||
"run": run.id,
|
||||
"kind": "excluded_region",
|
||||
"service": run.service,
|
||||
"tags": list(run.tags),
|
||||
"schematic_m": drawn_m,
|
||||
"reason": inside,
|
||||
}
|
||||
)
|
||||
continue
|
||||
ends = network["attachments"].get(run.id, [])
|
||||
drawn = sum(edge["length"] for edge in run.edges)
|
||||
schematic_m = drawn * m_pid
|
||||
@@ -401,16 +653,19 @@ def pipe_rows(
|
||||
part["drawn"] += edge["length"]
|
||||
part[edge["diameter_source"]] += edge["length"]
|
||||
why = None
|
||||
if any(end["tag"] is None for end in ends) or not ends:
|
||||
why = "end_not_on_equipment"
|
||||
elif len(run.tags) < 2:
|
||||
why = "single_tag"
|
||||
elif source == "layout":
|
||||
missing = [tag for tag in run.tags if tag not in positions]
|
||||
if missing:
|
||||
why = (
|
||||
"tag_ambiguous" if any(t in ambiguous for t in missing) else "tag_not_on_layout"
|
||||
)
|
||||
if source == "layout":
|
||||
if any(end["tag"] is None for end in ends) or not ends:
|
||||
why = "end_not_on_equipment"
|
||||
elif len(run.tags) < 2:
|
||||
why = "single_tag"
|
||||
else:
|
||||
missing = [tag for tag in run.tags if tag not in positions]
|
||||
if missing:
|
||||
why = (
|
||||
"tag_ambiguous"
|
||||
if any(t in ambiguous for t in missing)
|
||||
else "tag_not_on_layout"
|
||||
)
|
||||
detail = {
|
||||
"id": run.id,
|
||||
"service": run.service,
|
||||
@@ -468,15 +723,16 @@ def pipe_rows(
|
||||
detail["vertical_m"] = vertical * len(tree)
|
||||
length_m = detail["layout_m"] + detail["vertical_m"]
|
||||
else:
|
||||
if layout is not None and scale.get("verdict") == "to_scale":
|
||||
length_m = schematic_m / scale["factor"]
|
||||
else:
|
||||
length_m = schematic_m
|
||||
centre = (
|
||||
sum(end["at"][0] for end in ends) / len(ends),
|
||||
sum(end["at"][1] for end in ends) / len(ends),
|
||||
factor = (
|
||||
scale["factor"]
|
||||
if layout is not None and scale.get("verdict") == "to_scale"
|
||||
else 1.0
|
||||
)
|
||||
rooms = {room_at(regions, centre): length_m}
|
||||
length_m = schematic_m / factor
|
||||
cells = _pid_cells(run.edges, regions, m_pid / factor)
|
||||
rooms = {}
|
||||
for (room, _diameter), cell in cells.items():
|
||||
rooms[room] = rooms.get(room, 0.0) + cell["net"]
|
||||
detail["length_m"] = length_m
|
||||
detail["rooms"] = rooms
|
||||
status = (
|
||||
@@ -485,25 +741,25 @@ def pipe_rows(
|
||||
else "B"
|
||||
)
|
||||
detail["status"] = status
|
||||
if source == "pid":
|
||||
# measured piece by piece: each cell is one room and one diameter
|
||||
for (room, diameter), cell in cells.items():
|
||||
row = _row(rows, room, run.service, diameter)
|
||||
row["net_m"] += cell["net"]
|
||||
row["direct_m"] += cell["direct"]
|
||||
row["continuity_m"] += cell["continuity"]
|
||||
row["unassigned_m"] += cell["unassigned"]
|
||||
row["schematic_m"] += cell["net"] * factor
|
||||
if cell["continuity"] or cell["unassigned"]:
|
||||
row["status"] = "B"
|
||||
if run.id not in row["runs"]:
|
||||
row["runs"].append(run.id)
|
||||
continue
|
||||
for diameter, part in parts.items():
|
||||
share = part["drawn"] / drawn if drawn else 0.0
|
||||
for room, room_m in rooms.items():
|
||||
x = room_m * share
|
||||
row = rows.setdefault(
|
||||
(room, run.service, diameter),
|
||||
{
|
||||
"room": room,
|
||||
"service": run.service,
|
||||
"diameter": diameter,
|
||||
"direct_m": 0.0,
|
||||
"continuity_m": 0.0,
|
||||
"unassigned_m": 0.0,
|
||||
"net_m": 0.0,
|
||||
"schematic_m": 0.0,
|
||||
"status": "A",
|
||||
"runs": [],
|
||||
},
|
||||
)
|
||||
row = _row(rows, room, run.service, diameter)
|
||||
row["net_m"] += x
|
||||
row["direct_m"] += x * part["direct"] / part["drawn"]
|
||||
row["continuity_m"] += x * part["continuity"] / part["drawn"]
|
||||
@@ -549,6 +805,25 @@ def pipe_rows(
|
||||
f"({network['stats']['tol']:g} drawing units) collapsed to a point and are not "
|
||||
"counted"
|
||||
)
|
||||
flagged = [r for r in runs_out if r["status"] == "C"]
|
||||
if source == "layout" and flagged:
|
||||
warnings.append(
|
||||
f"{len(flagged)} of {len(runs_out)} runs could not be routed on the layout "
|
||||
f"({_reasons(flagged)}); their {unroutable:.1f} m drawn on the P&ID are in the "
|
||||
"control rows, not in the table. length_source='pid' with force=True measures "
|
||||
"every run on the P&ID instead, and says the scale is not verified."
|
||||
)
|
||||
counted_copies = [region for region in copies if not region["excluded"]]
|
||||
if counted_copies:
|
||||
listed = "; ".join(
|
||||
f"{region['id']}: {', '.join(region['tags'])} ({region['pipe_m']:.1f} m)"
|
||||
for region in counted_copies
|
||||
)
|
||||
warnings.append(
|
||||
f"{len(counted_copies)} possible detail cop{'y' if len(counted_copies) == 1 else 'ies'} "
|
||||
f"- tags drawn twice, close together - are counted like the rest ({listed}). If one "
|
||||
"repeats pipes drawn elsewhere, pass its id in exclude_regions."
|
||||
)
|
||||
if not scale_verified:
|
||||
warnings.append(
|
||||
"scale_verified: false - lengths are drawn lengths on a P&ID that no scale check "
|
||||
@@ -568,7 +843,9 @@ def pipe_rows(
|
||||
"allowance_m": net_total * allowance,
|
||||
"total_m": net_total * (1.0 + allowance),
|
||||
"flagged_runs": sum(1 for r in runs_out if r["status"] == "C"),
|
||||
"excluded_m": excluded_m,
|
||||
},
|
||||
"detail_copies": copies,
|
||||
"length_source": source,
|
||||
"scale": scale,
|
||||
"scale_verified": scale_verified,
|
||||
@@ -588,6 +865,7 @@ def pipe_rows(
|
||||
"overlap_length_m": network["stats"]["overlap_length"] * m_pid,
|
||||
"route": "rectilinear MST of the tag positions; each connection x first, then y",
|
||||
"rooms": _rooms_mode(regions),
|
||||
"exclude_regions": left_out,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -670,14 +948,22 @@ def _inside_box(p: Pt, box) -> bool:
|
||||
|
||||
|
||||
def cable_rows(
|
||||
pid: Snapshot, layout: Snapshot | None, *, allowance=0.20, section_rules=None
|
||||
pid: Snapshot,
|
||||
layout: Snapshot | None,
|
||||
*,
|
||||
allowance=0.20,
|
||||
section_rules=None,
|
||||
panel_rule="nearest",
|
||||
) -> dict:
|
||||
"""One row per electrical load, the cable method made repeatable (spec §9).
|
||||
|
||||
Loads are the P&ID tags with electrical texts near them (`parse_electrical`:
|
||||
kW, phases, voltage, + N, VFD) and the tags a wiring callout names. The
|
||||
panel comes from the layout's callouts (`wiring_target`: 'Wiring to CP1'
|
||||
-> CP-1), else the P&ID's. The length is the Manhattan distance on the
|
||||
-> CP-1), else the P&ID's; with ``panel_rule='nearest'`` (the default) a
|
||||
load no callout places, whose tag is on the layout, takes the nearest panel
|
||||
there and is flagged ``panel_inferred`` - ``'stated'`` leaves it open. An
|
||||
inferred panel never makes a load part of a package. The length is the Manhattan distance on the
|
||||
layout from the load's tag to its panel's tag, and the cable is
|
||||
`roundup_m(length, allowance)`. **Power is never invented**: a load with no
|
||||
stated kW keeps an empty cell and an open item. With no layout, lengths
|
||||
@@ -691,6 +977,10 @@ def cable_rows(
|
||||
"""
|
||||
allowance = check_allowance(allowance)
|
||||
rules = check_section_rules(section_rules)
|
||||
if panel_rule not in PANEL_RULES:
|
||||
raise ValueError(
|
||||
f"panel_rule: {panel_rule!r} unknown; choose from {', '.join(PANEL_RULES)}"
|
||||
)
|
||||
pid_places = {tag: [p["at"] for p in places] for tag, places in tag_occurrences(pid).items()}
|
||||
pid_search = CABLE_SEARCH_SHARE * label_search_default(pid)
|
||||
|
||||
@@ -753,6 +1043,8 @@ def cable_rows(
|
||||
room_regions(layout, cluster, prep=prep_layout) if layout is not None else room_regions(pid)
|
||||
)
|
||||
|
||||
# the panels a callout names anywhere, besides every tag with a panel prefix
|
||||
named = {target for target, _source in panels.values()}
|
||||
rows, items = [], []
|
||||
for tag in sorted(set(loads) | set(panels)):
|
||||
texts = sorted(loads.get(tag, []), key=lambda item: item[:2])
|
||||
@@ -769,6 +1061,16 @@ def cable_rows(
|
||||
phases = next((f["phases"] for _d, _h, f in texts if f["phases"]), None)
|
||||
voltage = next((f["voltage"] for _d, _h, f in texts if f["voltage"]), None)
|
||||
panel, panel_source = panels.get(tag, (None, None))
|
||||
inferred = None
|
||||
if panel is None and panel_rule == "nearest" and layout is not None:
|
||||
here_now = positions.get(tag)
|
||||
if here_now is not None:
|
||||
candidates = {
|
||||
name: p for name, p in panel_at.items() if name in named or is_panel_name(name)
|
||||
}
|
||||
inferred = _nearest_panel(here_now, candidates, exclude=normalize_panel(tag))
|
||||
if inferred is not None:
|
||||
panel, panel_source = inferred[0], "nearest"
|
||||
row = {
|
||||
"tag": tag,
|
||||
"room": "",
|
||||
@@ -802,6 +1104,16 @@ def cable_rows(
|
||||
"detail": "no wiring callout names a panel for this tag",
|
||||
}
|
||||
)
|
||||
elif panel_source == "nearest":
|
||||
items.append(
|
||||
{
|
||||
"tag": tag,
|
||||
"item": "panel_inferred",
|
||||
"detail": "no wiring callout names a panel; "
|
||||
f"{panel} is the nearest panel on the layout, "
|
||||
f"{inferred[1] * layout_unit['m_per_unit']:.1f} m away",
|
||||
}
|
||||
)
|
||||
if rules and kw is not None and row["section"] is None:
|
||||
items.append(
|
||||
{"tag": tag, "item": "no_section_rule", "detail": f"no rule covers {kw:g} kW"}
|
||||
@@ -846,7 +1158,9 @@ def cable_rows(
|
||||
(normalize_panel(r["tag"]) or r["tag"]): r["tag"] for r in rows if r["kw"] is not None
|
||||
}
|
||||
for row in rows:
|
||||
owner = stated.get(row["panel"]) if row["panel"] else None
|
||||
# only a panel the drawing names makes a package; a nearest guess does not
|
||||
known = row["panel"] and row["panel_source"] != "nearest"
|
||||
owner = stated.get(row["panel"]) if known else None
|
||||
if owner is not None and owner != row["tag"]:
|
||||
row["package"] = owner
|
||||
|
||||
@@ -887,6 +1201,7 @@ def cable_rows(
|
||||
"allowance": allowance,
|
||||
"search": {"pid": pid_search, "layout": layout_search},
|
||||
"section_rules": [dict(rule) for rule in rules],
|
||||
"panel_rule": panel_rule,
|
||||
"rooms": _rooms_mode(regions),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from engineering.understand.snapshot import EntityRecord, Pt, Snapshot
|
||||
from engineering.understand.vocab import classify_layer
|
||||
from engineering.understand.vocab import classify_layer, is_network_layer
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_TOL",
|
||||
@@ -219,7 +219,7 @@ def network_layers(snap: Snapshot) -> list[dict]:
|
||||
for name in sorted(names):
|
||||
meaning = classify_layer(name)
|
||||
confidence = float(meaning.get("confidence") or 0.0)
|
||||
if meaning.get("discipline") in NETWORK_DISCIPLINES and confidence >= NETWORK_CONFIDENCE:
|
||||
if is_network_layer(name):
|
||||
rows.append(
|
||||
{
|
||||
"layer": name,
|
||||
|
||||
@@ -32,6 +32,7 @@ __all__ = [
|
||||
"classify_layer",
|
||||
"equipment_kind",
|
||||
"fold",
|
||||
"is_network_layer",
|
||||
"room_label",
|
||||
"supply_return",
|
||||
]
|
||||
@@ -438,6 +439,43 @@ def room_label(text: str | None) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
#: Words that make a piping or electrical layer hold annotation, not lines: pipe
|
||||
#: sizes, materials, valve or line numbers, hatching. Matched like every other
|
||||
#: keyword here (a word of five letters or more anywhere in the name).
|
||||
_NOT_A_LINE_WORDS = (
|
||||
("annotation", "diameter", "en"),
|
||||
("annotation", "sizes", "en"),
|
||||
("annotation", "size", "en"),
|
||||
("annotation", "material", "en"),
|
||||
("annotation", "number", "en"),
|
||||
("annotation", "nummer", "nl"),
|
||||
("annotation", "arcering", "nl"),
|
||||
("annotation", "hatch", "en"),
|
||||
)
|
||||
|
||||
|
||||
def is_network_layer(name: str | None) -> bool:
|
||||
"""A layer whose lines are pipes or cables.
|
||||
|
||||
`classify_layer` files it under piping or electrical at 0.9, and its name
|
||||
says neither that it holds symbols - a valve, pump, tank, motor, panel or
|
||||
reducer (`equipment_kind`), whose outlines are no pipe - nor that it holds
|
||||
annotation: sizes, materials, numbers, hatching, or an annotation word.
|
||||
Field P&IDs keep valves and pumps on their own layers (``KLEPPEN``,
|
||||
``PRODUCT PUMPS``) that a service keyword alone would read as piping.
|
||||
"""
|
||||
meaning = classify_layer(name)
|
||||
if meaning["discipline"] not in ("piping", "electrical") or meaning["confidence"] < DIRECT:
|
||||
return False
|
||||
if equipment_kind(name) is not None:
|
||||
return False
|
||||
folded = fold(name)
|
||||
if _first(_NOT_A_LINE_WORDS, folded) is not None:
|
||||
return False
|
||||
annotation = [row for row in _DISCIPLINE_WORDS if row[0] == "annotation"]
|
||||
return _first(annotation, folded) is None
|
||||
|
||||
|
||||
def equipment_kind(block_name: str | None) -> dict | None:
|
||||
"""The kind of equipment a block name says it is: ``{"kind", "keyword", "language"}``.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "autocad-mcp-pro"
|
||||
version = "1.5.1"
|
||||
version = "1.6.0"
|
||||
description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -151,6 +151,10 @@ only-include = [
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
# Design documents and implementation plans quote code as illustration; ruff
|
||||
# 0.16 formats Markdown code blocks, and reflowing a plan's quoted code would
|
||||
# change what the document says was written.
|
||||
extend-exclude = ["docs/superpowers"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W", "UP", "B"]
|
||||
|
||||
@@ -29,9 +29,9 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
#: The published A/B report the README and benchmarks/README quote. Bump this,
|
||||
#: the two row labels below and the prose together, or the gate reports drift.
|
||||
AB_REPORT = "benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json"
|
||||
AB_REPORT = "benchmarks/results/published/ab-v1.5.1-vs-v1.6.0.json"
|
||||
AB_BASELINE_LABEL = r"v1\.5\.1"
|
||||
AB_CURRENT_LABEL = r"v1\.6\.0-dev"
|
||||
AB_CURRENT_LABEL = r"v1\.6\.0"
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
@@ -79,7 +79,7 @@ def derived() -> dict[str, object]:
|
||||
}
|
||||
|
||||
from benchmarks import correctness_suite
|
||||
from benchmarks.tasks_v6 import TASKS_V6
|
||||
from benchmarks.tasks_v7 import TASKS_V7
|
||||
|
||||
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),
|
||||
"matrix_tasks": len(TASKS_V6),
|
||||
"matrix_tasks": len(TASKS_V7),
|
||||
"perf_ms": perf,
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ CHECKS: list[tuple[str, str, str, str]] = [
|
||||
"README.md",
|
||||
"A/B fixed count",
|
||||
AB_CURRENT_LABEL
|
||||
+ r"\*\* \*\(this branch\)\* \| \*\*\d+ / \d+\*\* \| \*\*\d+ %\*\* \| (\d+)",
|
||||
+ r"\*\* \*\(this release\)\* \| \*\*\d+ / \d+\*\* \| \*\*\d+ %\*\* \| (\d+)",
|
||||
"ab_fixed",
|
||||
),
|
||||
(
|
||||
@@ -172,7 +172,7 @@ CHECKS: list[tuple[str, str, str, str]] = [
|
||||
(
|
||||
"benchmarks/README.md",
|
||||
"A/B fixed count",
|
||||
r"\*\*" + AB_CURRENT_LABEL + r"\*\* \(this branch\) \| \d+ / \d+ \| \d+ % \| (\d+)",
|
||||
r"\*\*" + AB_CURRENT_LABEL + r"\*\* \(this release\) \| \d+ / \d+ \| \d+ % \| (\d+)",
|
||||
"ab_fixed",
|
||||
),
|
||||
("CLAUDE.md", "A/B suite size", r"correctness_suite\.py` \((\d+) checks\)", "ab_total"),
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Run both takeoffs on a real P&ID and layout, and write the report next to them.
|
||||
|
||||
LOCAL ONLY. This is the field test of track H (spec §15): the user points it at
|
||||
a client's P&ID and layout, compares what comes out with the hand-made
|
||||
workbooks, and keeps the result on their own disk. Nothing it reads or writes
|
||||
may enter this repository (spec §14), so:
|
||||
|
||||
* the report is written next to the P&ID (or into ``--out``) and **the script
|
||||
refuses any output folder inside this repository**, before reading a byte;
|
||||
* the console gets file names, row counts and the scale verdict - **never a
|
||||
quantity**, unless ``--show`` is given on the user's own terminal;
|
||||
* it is never run in CI and never by a test on a real drawing; the test next
|
||||
to it (tests/test_field_test_takeoff.py) runs it on the synthetic plant pair.
|
||||
|
||||
What it writes: ``pipe_takeoff.xlsx`` / ``cable_takeoff.xlsx`` (CSV beside
|
||||
them always, XLSX when the ``office`` extra is installed) through the same
|
||||
``write_workbook`` the tools use, and ``field_test_report.json`` with the scale
|
||||
check and both results in full.
|
||||
|
||||
uv run --frozen python scripts/field_test_takeoff.py --pid D:/job/pid.dxf --layout D:/job/layout.dxf
|
||||
uv run --frozen python scripts/field_test_takeoff.py --pid ... --layout ... --lang en --show
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
#: Exit code for a refused run (output inside the repository, a missing file).
|
||||
REFUSED = 2
|
||||
|
||||
|
||||
def _inside_repository(folder: Path) -> bool:
|
||||
resolved = folder.resolve()
|
||||
return resolved == ROOT or ROOT in resolved.parents
|
||||
|
||||
|
||||
def _parse(argv) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pipe and cable takeoffs on a real P&ID + layout (local only)."
|
||||
)
|
||||
parser.add_argument("--pid", required=True, help="the P&ID, a .dxf")
|
||||
parser.add_argument("--layout", required=True, help="the layout, a .dxf")
|
||||
parser.add_argument("--out", default=None, help="report folder (default: the P&ID's folder)")
|
||||
parser.add_argument("--lang", default="tr", choices=("tr", "en", "ru"))
|
||||
parser.add_argument("--allowance", type=float, default=0.20)
|
||||
parser.add_argument(
|
||||
"--show", action="store_true", help="also print the totals (quantities) to the console"
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = _parse(argv)
|
||||
pid_path, layout_path = Path(args.pid), Path(args.layout)
|
||||
out = Path(args.out) if args.out else pid_path.resolve().parent
|
||||
if _inside_repository(out):
|
||||
print(
|
||||
f"refused: {out} is inside the repository ({ROOT}); client drawings and "
|
||||
"anything derived from them never enter it. Pass --out outside it."
|
||||
)
|
||||
return REFUSED
|
||||
for path in (pid_path, layout_path):
|
||||
if not path.is_file():
|
||||
print(f"refused: {path} does not exist")
|
||||
return REFUSED
|
||||
|
||||
from engineering.understand.network import build_network
|
||||
from engineering.understand.report import write_workbook
|
||||
from engineering.understand.scale import scale_check
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from engineering.understand.takeoff import cable_rows, pipe_rows
|
||||
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
pid = read_snapshot(str(pid_path))
|
||||
layout = read_snapshot(str(layout_path))
|
||||
scale = scale_check(pid, layout)
|
||||
pipe = pipe_rows(build_network(pid), pid, layout, scale=scale, allowance=args.allowance)
|
||||
cable = cable_rows(pid, layout, allowance=args.allowance)
|
||||
written = [
|
||||
write_workbook(pipe, kind="pipe", lang=args.lang, path=str(out / "pipe_takeoff.xlsx")),
|
||||
write_workbook(cable, kind="cable", lang=args.lang, path=str(out / "cable_takeoff.xlsx")),
|
||||
]
|
||||
report = out / "field_test_report.json"
|
||||
report.write_text(
|
||||
json.dumps(
|
||||
{"scale": scale, "pipe": pipe, "cable": cable, "written": written},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"scale_verdict": scale["verdict"],
|
||||
"pipe_rows": len(pipe["rows"]),
|
||||
"cable_rows": len(cable["rows"]),
|
||||
"report": str(report),
|
||||
"workbooks": [
|
||||
path
|
||||
for item in written
|
||||
for path in ([item["xlsx"]] if item["xlsx"] else []) + list(item["csv"])
|
||||
],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
if args.show:
|
||||
print(
|
||||
json.dumps(
|
||||
{"pipe": pipe["rows"], "cable": cable["rows"]},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Render the floor plan the README shows, using only the server's own tools.
|
||||
|
||||
The plan is ``EXAMPLE_SPEC`` from ``engineering/arch/spec.py`` - the document
|
||||
``arch_plan_from_spec`` draws in one call: a 9 x 6 m brick ring split by an AAC
|
||||
wall, an entrance door, an interior door, two windows, a straight stair, two
|
||||
rooms whose areas are measured from the faces the walls enclose, the exterior
|
||||
dimension chains and the door / window / room schedules. The ``arch_roundtrip``
|
||||
benchmark task gates on the same spec, so a regression changes both.
|
||||
|
||||
Reproduce:
|
||||
|
||||
AUTOCAD_MCP_BACKEND=ezdxf uv run --frozen python scripts/render_readme_arch.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "arch-showcase.png"
|
||||
|
||||
|
||||
async def render(output: Path) -> dict:
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
from engineering.arch.spec import EXAMPLE_SPEC, draw_plan_from_spec
|
||||
from engineering.critique import run_critique
|
||||
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
try:
|
||||
await backend.drawing_new()
|
||||
result = await draw_plan_from_spec(backend, EXAMPLE_SPEC)
|
||||
issues = await run_critique(backend, None)
|
||||
png = await backend.view_screenshot()
|
||||
if not png:
|
||||
raise RuntimeError("view_screenshot returned no PNG (is matplotlib installed?)")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(png)
|
||||
return {
|
||||
"output": str(output),
|
||||
"bytes": len(png),
|
||||
"omitted": len(result.get("omitted", [])),
|
||||
"critique": len(issues),
|
||||
}
|
||||
finally:
|
||||
await backend.disconnect()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
args = parser.parse_args()
|
||||
print(asyncio.run(render(args.output)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Render the mechanical sheet the README shows, using only the server's own tools.
|
||||
|
||||
The sheet is the one the ``mech_assembly`` benchmark task gates on - a flange
|
||||
coupling: a hub with a full section, a 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 ISO 6433 balloons, an ISO 5457 frame and an ISO 7200 title block. It is
|
||||
built by the benchmark's own task, so the picture and the gate cannot drift
|
||||
apart: if the view engine or the sheet standard regresses, both change.
|
||||
|
||||
Reproduce:
|
||||
|
||||
AUTOCAD_MCP_BACKEND=ezdxf uv run --frozen python scripts/render_readme_mech.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "mech-showcase.png"
|
||||
|
||||
|
||||
async def render(output: Path) -> dict:
|
||||
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
|
||||
|
||||
adapter = AutoCADMCPProAdapter(backend="ezdxf")
|
||||
with tempfile.TemporaryDirectory(prefix="readme_mech_") as folder:
|
||||
await adapter.setup(Path(folder))
|
||||
try:
|
||||
await adapter.backend.drawing_new() # the harness opens one before every task
|
||||
passed, metrics = (await adapter._task_mech_assembly())[:2]
|
||||
png = await adapter.backend.view_screenshot()
|
||||
finally:
|
||||
await adapter.cleanup()
|
||||
if not png:
|
||||
raise RuntimeError("view_screenshot returned no PNG (is matplotlib installed?)")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(png)
|
||||
return {
|
||||
"output": str(output),
|
||||
"bytes": len(png),
|
||||
"gate_passed": passed,
|
||||
"critique_issues": metrics["critique_issues"],
|
||||
"score": metrics["score"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
args = parser.parse_args()
|
||||
print(asyncio.run(render(args.output)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Exercise track H once against the live AutoCAD on this machine.
|
||||
|
||||
Builds the synthetic plant pair (tests/fixtures/plant_pair.py) in a temporary
|
||||
folder and reads it through the live engine - never an operator's drawing:
|
||||
|
||||
open pid.dxf -> take_snapshot(backend) (Document.Export, "DXF")
|
||||
-> drawing_save_as(pid.dwg), close it
|
||||
-> take_snapshot(backend, pid.dwg) (opened read-only, exported, closed)
|
||||
open layout.dxf -> take_snapshot(backend)
|
||||
pipe_rows / cable_rows on the two live snapshots, against the pair's truth
|
||||
entity_move one tag text by 10 mm -> take_snapshot -> diff_snapshots
|
||||
against the snapshot taken before the move
|
||||
|
||||
and prints what came back as JSON. Exit codes:
|
||||
|
||||
0 every live snapshot holds the same model-space entities (by type) as the
|
||||
file it came from, the takeoffs match the truth, and the diff reports
|
||||
exactly the one moved entity
|
||||
1 it ran, but something disagreed (the JSON says what)
|
||||
2 no live CAD application reachable (pywin32 missing, or no running
|
||||
instance to attach to) -- the COM paths then remain fake-tested only
|
||||
|
||||
**The operator's drawings are never touched.** AutoCAD may have other
|
||||
documents open, with unsaved work in them, while this runs. So:
|
||||
|
||||
* the smoke only ever attaches to a running instance (``GetActiveObject``);
|
||||
it never creates one, because ``Dispatch`` would launch AutoCAD;
|
||||
* every document it works in is one it brought in itself from the files it
|
||||
just wrote into its own temporary folder, and it knows them by full path -
|
||||
never by a name another document could share;
|
||||
* every call that reads or writes *the current document* (a live snapshot, the
|
||||
DWG save, the move) first checks, inside the same COM-thread call, that the
|
||||
active document is the smoke's own - and refuses otherwise;
|
||||
* on the way out (also when a step fails) it closes its own documents with
|
||||
``Close(False)`` and re-activates the document that was active when it
|
||||
started; unless ``--keep`` is given, then its documents stay open;
|
||||
* nothing it prints names a document other than its own.
|
||||
|
||||
AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_understand_com.py
|
||||
uv run --frozen python scripts/smoke_understand_com.py --keep # leave them open
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
#: ``RPC_E_CALL_REJECTED``: AutoCAD's message filter refused the call because
|
||||
#: it was busy (a document still initialising after ``Documents.Open``). 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
|
||||
|
||||
#: Full paths (normalised) of the documents this run opened and has not closed.
|
||||
_OPENED: list[str] = []
|
||||
|
||||
#: The run's state shared with the COM thread: the folder its files live in,
|
||||
#: the document that must be active for the next current-document call (None:
|
||||
#: the call does not touch the current document), and the document that was
|
||||
#: active when the run started (a COM object, used on the COM thread only).
|
||||
_STATE: dict = {"folder": None, "expect": None, "start": None}
|
||||
|
||||
|
||||
class NotOurDocument(RuntimeError):
|
||||
"""The active document is not the one the smoke is about to read or write."""
|
||||
|
||||
|
||||
def _key(path) -> str:
|
||||
return str(Path(str(path)).resolve()).lower()
|
||||
|
||||
|
||||
def _ours(path) -> bool:
|
||||
folder = _STATE["folder"]
|
||||
return bool(path) and folder is not None and Path(_key(path)).parent == Path(_key(folder))
|
||||
|
||||
|
||||
def _check_active() -> None:
|
||||
"""COM thread: refuse unless the active document is the expected own one."""
|
||||
expected = _STATE["expect"]
|
||||
if expected is None:
|
||||
return
|
||||
from backends import com_backend as com
|
||||
|
||||
app = com._acad_app()
|
||||
active = str(app.ActiveDocument.FullName) if int(app.Documents.Count) else ""
|
||||
if not active or _key(active) != expected:
|
||||
# Never name the other document: it may be an operator's drawing.
|
||||
raise NotOurDocument(
|
||||
f"refused: the active document is not the smoke's own {Path(expected).name}"
|
||||
)
|
||||
|
||||
|
||||
def _patient(backend) -> None:
|
||||
"""Retry any call AutoCAD refused with ``RPC_E_CALL_REJECTED``, and run the
|
||||
active-document check in the same COM-thread call as the call it guards."""
|
||||
run = backend._run
|
||||
|
||||
async def patient(func, *args, **kwargs):
|
||||
def guarded(*a, **kw):
|
||||
_check_active()
|
||||
return func(*a, **kw)
|
||||
|
||||
deadline = time.monotonic() + REJECTED_PATIENCE_S
|
||||
pause = 0.5
|
||||
while True:
|
||||
try:
|
||||
return await run(guarded, *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
|
||||
|
||||
|
||||
class _Current:
|
||||
"""``async with _Current(path):`` - calls inside it must find ``path`` active."""
|
||||
|
||||
def __init__(self, path) -> None:
|
||||
self.path = _key(path)
|
||||
|
||||
async def __aenter__(self):
|
||||
_STATE["expect"] = self.path
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
_STATE["expect"] = None
|
||||
return False
|
||||
|
||||
|
||||
async def _attach(backend) -> str | None:
|
||||
"""Attach to the running instance only, and remember the active document.
|
||||
|
||||
Returns the application's version, or None when nothing is running (the
|
||||
backend's own ``_acad_app`` would fall back to ``Dispatch`` and launch
|
||||
AutoCAD - a smoke must never do that)."""
|
||||
import win32com.client
|
||||
|
||||
import config
|
||||
from backends import com_backend as com
|
||||
|
||||
def _sync():
|
||||
try:
|
||||
app = win32com.client.GetActiveObject(config.settings.cad_progid)
|
||||
except Exception:
|
||||
return None
|
||||
com._COM_STATE["app"] = app
|
||||
_STATE["start"] = app.ActiveDocument if int(app.Documents.Count) else None
|
||||
return str(app.Version)
|
||||
|
||||
return await backend._run(_sync)
|
||||
|
||||
|
||||
async def _open_paths(backend) -> set[str]:
|
||||
"""Full paths of the open documents that are the smoke's own (COM thread)."""
|
||||
|
||||
def _sync():
|
||||
from backends import com_backend as com
|
||||
|
||||
app = com._acad_app()
|
||||
paths = (str(app.Documents.Item(i).FullName) for i in range(int(app.Documents.Count)))
|
||||
return {_key(p) for p in paths if _ours(p)}
|
||||
|
||||
return await backend._run(_sync)
|
||||
|
||||
|
||||
async def _open(backend, path) -> str:
|
||||
"""``drawing_open`` of one of the smoke's own files; returns its key."""
|
||||
if not _ours(path):
|
||||
raise NotOurDocument(f"refused: {Path(path).name} is not in the smoke's own folder")
|
||||
result = await backend.drawing_open(str(path))
|
||||
key = _key(result.get("path") or path)
|
||||
if key not in _OPENED:
|
||||
_OPENED.append(key)
|
||||
return key
|
||||
|
||||
|
||||
async def _close(backend, key: str) -> None:
|
||||
"""Close one of the smoke's own documents, unsaved (``Close(False)``)."""
|
||||
if not _ours(key):
|
||||
raise NotOurDocument("refused: not one of the smoke's own documents")
|
||||
if key in await _open_paths(backend):
|
||||
await backend.document_close(key, discard=True)
|
||||
if key in _OPENED:
|
||||
_OPENED.remove(key)
|
||||
|
||||
|
||||
async def _close_leftovers(backend) -> None:
|
||||
for key in list(_OPENED):
|
||||
try:
|
||||
await _close(backend, key)
|
||||
except Exception as exc: # best effort on the way out
|
||||
print(f"could not close {Path(key).name}: {type(exc).__name__}", file=sys.stderr)
|
||||
if key in _OPENED:
|
||||
_OPENED.remove(key)
|
||||
|
||||
|
||||
async def _reactivate_start(backend) -> None:
|
||||
"""Hand the seat back as it was: the starting document active again."""
|
||||
|
||||
def _sync():
|
||||
start = _STATE["start"]
|
||||
if start is None:
|
||||
return
|
||||
try:
|
||||
start.Activate()
|
||||
except Exception: # it may have been closed by its owner meanwhile
|
||||
pass
|
||||
|
||||
try:
|
||||
await backend._run(_sync)
|
||||
except Exception as exc:
|
||||
print(f"could not re-activate the starting document: {type(exc).__name__}", file=sys.stderr)
|
||||
|
||||
|
||||
def _model(snapshot):
|
||||
"""The snapshot with its model-space records only (AutoCAD adds a paper-space
|
||||
viewport when it initialises a layout; the takeoffs read model space)."""
|
||||
return dataclasses.replace(
|
||||
snapshot, records=tuple(r for r in snapshot.records if r.space == "Model")
|
||||
)
|
||||
|
||||
|
||||
def _types(snapshot) -> dict[str, int]:
|
||||
return dict(sorted(Counter(r.type for r in _model(snapshot).records).items()))
|
||||
|
||||
|
||||
def _check_takeoffs(truth, pid, layout) -> dict:
|
||||
from engineering.understand.network import build_network
|
||||
from engineering.understand.scale import scale_check
|
||||
from engineering.understand.takeoff import cable_rows, pipe_rows
|
||||
|
||||
scale = scale_check(pid, layout)
|
||||
services = tuple(sorted({run["service"] for run in truth["pipe_runs"]}))
|
||||
pipe = pipe_rows(build_network(pid), pid, layout, scale=scale, services=services)
|
||||
found = {(r["service"], tuple(sorted(r["tags"]))): r for r in pipe["runs"]}
|
||||
pipe_mismatch = []
|
||||
for run in truth["pipe_runs"]:
|
||||
got = found.get((run["service"], tuple(run["tags"])))
|
||||
want = run["layout_length_mm"]
|
||||
have = None if got is None else got.get("layout_m")
|
||||
agrees = got is not None and got["status"] == run["status"]
|
||||
if want is None:
|
||||
agrees = agrees and have is None
|
||||
else:
|
||||
agrees = agrees and have is not None and abs(float(have) * 1000.0 - want) <= 1e-6
|
||||
if not agrees:
|
||||
pipe_mismatch.append({"run": run["id"], "want": want, "have": have})
|
||||
rows = {row["tag"]: row for row in cable_rows(pid, layout)["rows"]}
|
||||
cable_mismatch = [
|
||||
want["tag"]
|
||||
for want in truth["cable_rows"]
|
||||
if rows.get(want["tag"], {}).get("cable_m") != want["roundup_m"]
|
||||
or rows.get(want["tag"], {}).get("panel") != want["panel"]
|
||||
]
|
||||
return {
|
||||
"scale_verdict": scale["verdict"],
|
||||
"within_10pct": scale.get("within_10pct"),
|
||||
"runs_checked": len(truth["pipe_runs"]),
|
||||
"pipe_mismatch": pipe_mismatch,
|
||||
"loads_checked": len(truth["cable_rows"]),
|
||||
"cable_mismatch": cable_mismatch,
|
||||
}
|
||||
|
||||
|
||||
async def _smoke(backend, folder: Path) -> dict:
|
||||
from engineering.understand.diff import diff_snapshots
|
||||
from engineering.understand.snapshot import read_snapshot, take_snapshot
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
truth = build_plant_pair(folder)
|
||||
|
||||
pid_key = await _open(backend, truth["pid"])
|
||||
async with _Current(pid_key):
|
||||
started = time.perf_counter()
|
||||
live_pid = await take_snapshot(backend)
|
||||
export_s = time.perf_counter() - started
|
||||
dwg = folder / "pid.dwg"
|
||||
await backend.drawing_save_as(str(dwg), "dwg")
|
||||
# SaveAs renamed the document: it is now pid.dwg, and still the smoke's own.
|
||||
dwg_key = _key(dwg)
|
||||
_OPENED.append(dwg_key)
|
||||
for key in (pid_key, dwg_key):
|
||||
await _close(backend, key)
|
||||
dwg_pid = await take_snapshot(backend, str(dwg))
|
||||
|
||||
layout_key = await _open(backend, truth["layout"])
|
||||
async with _Current(layout_key):
|
||||
live_layout = await take_snapshot(backend)
|
||||
|
||||
takeoffs = _check_takeoffs(truth, _model(live_pid), _model(live_layout))
|
||||
|
||||
tag = next(
|
||||
r
|
||||
for r in live_layout.records
|
||||
if r.type == "TEXT" and r.layer == "TAGS" and r.space == "Model"
|
||||
)
|
||||
async with _Current(layout_key):
|
||||
await backend.entity_move(tag.handle, 10.0, 0.0)
|
||||
after = await take_snapshot(backend)
|
||||
diff = diff_snapshots(_model(live_layout), _model(after))
|
||||
await _close(backend, layout_key)
|
||||
|
||||
file_pid, file_layout = read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
|
||||
return {
|
||||
"export_s": round(export_s, 3),
|
||||
"live_pid_source": live_pid.source,
|
||||
"dwg_pid_source": dwg_pid.source,
|
||||
"model_space": {
|
||||
"pid_file": _types(file_pid),
|
||||
"pid_live": _types(live_pid),
|
||||
"pid_dwg": _types(dwg_pid),
|
||||
"layout_file": _types(file_layout),
|
||||
"layout_live": _types(live_layout),
|
||||
},
|
||||
"paper_space_layouts": sorted({r.space for r in live_layout.records} - {"Model"}),
|
||||
"takeoffs": takeoffs,
|
||||
"diff": {
|
||||
"moved": tag.handle,
|
||||
"added": len(diff["added"]),
|
||||
"removed": len(diff["removed"]),
|
||||
"changed": len(diff["changed"]),
|
||||
"moved_is_the_change": f'"{tag.handle}"' in json.dumps(diff["changed"]),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
try:
|
||||
await backend.connect()
|
||||
except Exception as exc:
|
||||
print(f"no live AutoCAD reachable: {exc}")
|
||||
return 2
|
||||
_patient(backend)
|
||||
folder = Path(tempfile.mkdtemp(prefix="acadmcp_understand_smoke_"))
|
||||
_STATE["folder"] = str(folder)
|
||||
try:
|
||||
version = await _attach(backend)
|
||||
if version is None:
|
||||
print("no live AutoCAD reachable: no running instance to attach to")
|
||||
return 2
|
||||
report = {"autocad": version, **await _smoke(backend, folder)}
|
||||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||||
spaces = report["model_space"]
|
||||
ok = (
|
||||
spaces["pid_live"] == spaces["pid_file"] == spaces["pid_dwg"]
|
||||
and spaces["layout_live"] == spaces["layout_file"]
|
||||
and report["takeoffs"]["scale_verdict"] == "schematic"
|
||||
and not report["takeoffs"]["pipe_mismatch"]
|
||||
and not report["takeoffs"]["cable_mismatch"]
|
||||
and report["diff"]["changed"] == 1
|
||||
and report["diff"]["added"] == report["diff"]["removed"] == 0
|
||||
and report["diff"]["moved_is_the_change"]
|
||||
)
|
||||
return 0 if ok else 1
|
||||
finally:
|
||||
_STATE["expect"] = None
|
||||
if not keep_document:
|
||||
await _close_leftovers(backend)
|
||||
await _reactivate_start(backend)
|
||||
_STATE["start"] = None
|
||||
await backend.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main(keep_document="--keep" in sys.argv[1:])))
|
||||
@@ -2,17 +2,17 @@
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.u-c4n/autocad-mcp",
|
||||
"title": "AutoCAD MCP Pro",
|
||||
"description": "AutoCAD automation for AI agents: live COM or headless ezdxf, ISO dims, GD&T, quality gate.",
|
||||
"description": "AutoCAD for AI agents, live or headless: drafting, P&ID, mechanical parts, architecture, takeoffs.",
|
||||
"repository": {
|
||||
"url": "https://github.com/U-C4N/Autocad-MCP",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "autocad-mcp-pro",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
@@ -38,6 +38,13 @@
|
||||
"format": "string",
|
||||
"isSecret": false
|
||||
},
|
||||
{
|
||||
"name": "TOOL_PACKS",
|
||||
"description": "Vertical packs to advertise: all (default) or a comma list of core, pid, settings, mech, arch, plant; core is always on.",
|
||||
"isRequired": false,
|
||||
"format": "string",
|
||||
"isSecret": false
|
||||
},
|
||||
{
|
||||
"name": "DISCOVERY_MODE",
|
||||
"description": "Tool discovery: off (default) advertises the full catalog; search replaces it with search_tools plus a call_tool proxy.",
|
||||
|
||||
@@ -1089,6 +1089,9 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
|
||||
}
|
||||
|
||||
_active_tool_profile: dict | None = None
|
||||
#: The Visibility transforms the last _apply_tool_profile added, detached before
|
||||
#: the next one is applied.
|
||||
_profile_transforms: list = []
|
||||
|
||||
|
||||
def _enabled_packs() -> tuple[set[str], list[str]]:
|
||||
@@ -1160,10 +1163,23 @@ async def _apply_tool_profile(profile: str | None = None) -> dict:
|
||||
registered = {tool.name for tool in await _registered_tools() if getattr(tool, "name", None)}
|
||||
enabled = _profile_enabled_names(selected, registered)
|
||||
disabled = sorted(registered - enabled)
|
||||
# Replace the previous profile's visibility, never stack on it. fastmcp 3's
|
||||
# enable()/disable() append a Visibility transform per call and a tool lookup
|
||||
# walks the whole stack as nested calls; the lifespan runs this on every
|
||||
# client connection, so an in-process server grew two layers per session
|
||||
# until every tool call raised RecursionError (Linux CI, CPython 3.11). The
|
||||
# same detach-first idiom as _apply_discovery_mode.
|
||||
for transform in _profile_transforms:
|
||||
try:
|
||||
mcp._transforms.remove(transform)
|
||||
except (AttributeError, ValueError) as exc: # pragma: no cover - layout change
|
||||
log.debug("Could not detach a tool-profile transform: %s", exc)
|
||||
before = list(mcp.transforms)
|
||||
if enabled:
|
||||
mcp.enable(names=set(enabled))
|
||||
if disabled:
|
||||
mcp.disable(names=set(disabled))
|
||||
_profile_transforms[:] = [t for t in mcp.transforms if not any(t is b for b in before)]
|
||||
_active_tool_profile = {
|
||||
"profile": selected,
|
||||
"registered_count": len(registered),
|
||||
@@ -10090,8 +10106,9 @@ async def arch_dimension_chains(
|
||||
own faces, read back from the plan on the drawing. A row that would only
|
||||
restate a farther one is not drawn (ISO 129-1: each measurement once).
|
||||
The row spacing defaults (10 mm, then 8 mm on paper) are this server's
|
||||
drafting choices, not standard values. Text height follows the current
|
||||
dimension style: at 1:50 set DIMSCALE=50 with `dimstyle_modify`.
|
||||
drafting choices, not standard values. Text, arrows and gaps follow the
|
||||
plot scale: each chain is drawn at DIMSCALE = `scale` (reported as
|
||||
`dimscale`), and the drawing's own DIMSCALE is put back afterwards.
|
||||
|
||||
Refused by name: a drawing with no walls, an unknown or repeated side, a
|
||||
non-positive scale, offset or step.
|
||||
@@ -11149,6 +11166,14 @@ async def pipe_takeoff(
|
||||
"drawing, 0.001 on a metre one).",
|
||||
),
|
||||
] = None,
|
||||
exclude_regions: Annotated[
|
||||
list[str] | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Ids of reported detail copies (detail_copies: D1, D2 ...) whose runs "
|
||||
"repeat pipes drawn elsewhere and are left out. Omitted, every copy is counted.",
|
||||
),
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""A pipe takeoff: topology from the P&ID, lengths from the layout, rows by room x service x diameter.
|
||||
@@ -11201,6 +11226,7 @@ async def pipe_takeoff(
|
||||
force=force,
|
||||
layers=layers,
|
||||
tol=tol,
|
||||
exclude_regions=exclude_regions,
|
||||
)
|
||||
except (ValueError, OSError) as exc:
|
||||
raise ToolError(str(exc)) from exc
|
||||
@@ -11263,6 +11289,14 @@ async def cable_takeoff(
|
||||
"e.g. [{'max_kw': 5.5, 'section': '5x2.5'}]. Omitted, no section is proposed.",
|
||||
),
|
||||
] = None,
|
||||
panel_rule: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="nearest",
|
||||
description="A load no wiring callout places: 'nearest' takes the nearest panel on "
|
||||
"the layout and flags the row panel_inferred; 'stated' leaves it open.",
|
||||
),
|
||||
] = "nearest",
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""A cable takeoff: one row per electrical load, the shop's cable method made repeatable.
|
||||
@@ -11271,7 +11305,9 @@ async def cable_takeoff(
|
||||
texts near each tag (Russian, Turkish and English spellings). **Power is
|
||||
never invented**: a load with no stated kW keeps an empty cell and an open
|
||||
item. The panel comes from the layout's wiring callouts ('Wiring to CP1'
|
||||
and its variants, normalised to CP-1), else from the P&ID. The length is the
|
||||
and its variants, normalised to CP-1), else from the P&ID ('к CP1'); a load
|
||||
neither places takes the nearest panel on the layout, flagged
|
||||
`panel_inferred` (`panel_rule='stated'` leaves it open). The length is the
|
||||
Manhattan distance on the layout from the load's tag to its panel's tag,
|
||||
and the cable is ROUNDUP(length x (1 + allowance)) to the metre. Room and
|
||||
panel totals count known power once: a load wired to a panel that states
|
||||
@@ -11306,6 +11342,7 @@ async def cable_takeoff(
|
||||
lang=lang,
|
||||
allowance=allowance,
|
||||
section_rules=section_rules,
|
||||
panel_rule=panel_rule,
|
||||
)
|
||||
except (ValueError, OSError) as exc:
|
||||
raise ToolError(str(exc)) from exc
|
||||
|
||||
@@ -489,7 +489,7 @@ $SKPOLY
|
||||
9
|
||||
$TDCREATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUCREATE
|
||||
40
|
||||
@@ -497,7 +497,7 @@ $TDUCREATE
|
||||
9
|
||||
$TDUPDATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUUPDATE
|
||||
40
|
||||
@@ -929,11 +929,11 @@ $PSTYLEMODE
|
||||
9
|
||||
$FINGERPRINTGUID
|
||||
2
|
||||
{4D9D8563-5BFB-473B-8F1E-D1D0F3D0DAFA}
|
||||
{FB139631-7B1A-47EB-AF87-B1687B365FDD}
|
||||
9
|
||||
$VERSIONGUID
|
||||
2
|
||||
{8D04634D-773A-4616-8A64-559AF6DC2617}
|
||||
{6BE2B494-ED83-4913-8849-67761A7D0FDA}
|
||||
9
|
||||
$EXTNAMES
|
||||
290
|
||||
@@ -2037,7 +2037,7 @@ Standard
|
||||
42
|
||||
2.5
|
||||
3
|
||||
txt
|
||||
arial.ttf
|
||||
4
|
||||
|
||||
0
|
||||
@@ -3591,7 +3591,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.938108+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.891596+00:00
|
||||
0
|
||||
DICTIONARYVAR
|
||||
5
|
||||
@@ -3603,7 +3603,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.946903+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.900113+00:00
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
|
||||
@@ -489,7 +489,7 @@ $SKPOLY
|
||||
9
|
||||
$TDCREATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUCREATE
|
||||
40
|
||||
@@ -497,7 +497,7 @@ $TDUCREATE
|
||||
9
|
||||
$TDUPDATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUUPDATE
|
||||
40
|
||||
@@ -929,11 +929,11 @@ $PSTYLEMODE
|
||||
9
|
||||
$FINGERPRINTGUID
|
||||
2
|
||||
{EFF5DFA9-C336-4985-B6C0-43235A21CA22}
|
||||
{3E50F756-652E-48B0-AE41-6BC3AFA2655A}
|
||||
9
|
||||
$VERSIONGUID
|
||||
2
|
||||
{FAECDBB5-1575-421E-8ABB-3C85F40DB897}
|
||||
{D664D6B0-1825-4401-AB82-8B8A64CDCC28}
|
||||
9
|
||||
$EXTNAMES
|
||||
290
|
||||
@@ -2037,7 +2037,7 @@ Standard
|
||||
42
|
||||
2.5
|
||||
3
|
||||
txt
|
||||
arial.ttf
|
||||
4
|
||||
|
||||
0
|
||||
@@ -3591,7 +3591,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.949904+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.904120+00:00
|
||||
0
|
||||
DICTIONARYVAR
|
||||
5
|
||||
@@ -3603,7 +3603,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.956981+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.910627+00:00
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
|
||||
@@ -489,7 +489,7 @@ $SKPOLY
|
||||
9
|
||||
$TDCREATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUCREATE
|
||||
40
|
||||
@@ -497,7 +497,7 @@ $TDUCREATE
|
||||
9
|
||||
$TDUPDATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUUPDATE
|
||||
40
|
||||
@@ -929,11 +929,11 @@ $PSTYLEMODE
|
||||
9
|
||||
$FINGERPRINTGUID
|
||||
2
|
||||
{6F1F6CA9-24DC-4EFD-8119-161D0C1B13F5}
|
||||
{D6D14E50-062D-4B6D-94BD-7BBE205F2BDD}
|
||||
9
|
||||
$VERSIONGUID
|
||||
2
|
||||
{BB862168-CC9E-4423-9C5D-4DD3A99B384D}
|
||||
{D46D597C-787A-4F32-8632-E6227A415146}
|
||||
9
|
||||
$EXTNAMES
|
||||
290
|
||||
@@ -2037,7 +2037,7 @@ Standard
|
||||
42
|
||||
2.5
|
||||
3
|
||||
txt
|
||||
arial.ttf
|
||||
4
|
||||
|
||||
0
|
||||
@@ -3663,7 +3663,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.902394+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.835350+00:00
|
||||
0
|
||||
SCALE
|
||||
5
|
||||
@@ -4007,7 +4007,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.911407+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.863902+00:00
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
|
||||
@@ -489,7 +489,7 @@ $SKPOLY
|
||||
9
|
||||
$TDCREATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUCREATE
|
||||
40
|
||||
@@ -497,7 +497,7 @@ $TDUCREATE
|
||||
9
|
||||
$TDUPDATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUUPDATE
|
||||
40
|
||||
@@ -929,11 +929,11 @@ $PSTYLEMODE
|
||||
9
|
||||
$FINGERPRINTGUID
|
||||
2
|
||||
{522C5F29-75D3-427F-A718-E45B971550C2}
|
||||
{68192C9F-2E1B-4491-B3B5-24934A927E62}
|
||||
9
|
||||
$VERSIONGUID
|
||||
2
|
||||
{CF5A954B-3734-4AD2-B15E-F6411516FB5E}
|
||||
{B09ECF76-6F17-4166-8AA8-1673B93563F5}
|
||||
9
|
||||
$EXTNAMES
|
||||
290
|
||||
@@ -2037,7 +2037,7 @@ Standard
|
||||
42
|
||||
2.5
|
||||
3
|
||||
txt
|
||||
arial.ttf
|
||||
4
|
||||
|
||||
0
|
||||
@@ -4541,7 +4541,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.742891+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.680466+00:00
|
||||
0
|
||||
DICTIONARYVAR
|
||||
5
|
||||
@@ -4553,7 +4553,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.898227+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.829339+00:00
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
|
||||
@@ -489,7 +489,7 @@ $SKPOLY
|
||||
9
|
||||
$TDCREATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUCREATE
|
||||
40
|
||||
@@ -497,7 +497,7 @@ $TDUCREATE
|
||||
9
|
||||
$TDUPDATE
|
||||
40
|
||||
2461306.528125
|
||||
2461308.9858101853
|
||||
9
|
||||
$TDUUPDATE
|
||||
40
|
||||
@@ -929,11 +929,11 @@ $PSTYLEMODE
|
||||
9
|
||||
$FINGERPRINTGUID
|
||||
2
|
||||
{629792F3-C911-49E6-B9A0-300331D1DFBB}
|
||||
{F0BBB091-B6E3-4896-AD21-EB815B809E89}
|
||||
9
|
||||
$VERSIONGUID
|
||||
2
|
||||
{ACABCD08-CEC8-4984-94D2-99D2ED56844B}
|
||||
{CA4E384E-EE93-48E0-A671-949D2BCF158F}
|
||||
9
|
||||
$EXTNAMES
|
||||
290
|
||||
@@ -2205,7 +2205,7 @@ Standard
|
||||
42
|
||||
2.5
|
||||
3
|
||||
txt
|
||||
arial.ttf
|
||||
4
|
||||
|
||||
0
|
||||
@@ -4709,7 +4709,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.915301+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.868575+00:00
|
||||
0
|
||||
DICTIONARYVAR
|
||||
5
|
||||
@@ -4721,7 +4721,7 @@ DictionaryVariables
|
||||
280
|
||||
0
|
||||
1
|
||||
1.4.4 @ 2026-09-22T09:40:30.933095+00:00
|
||||
1.4.4 @ 2026-09-24T20:39:34.887094+00:00
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
|
||||
@@ -197,6 +197,27 @@ async def test_draw_chains_puts_real_dimensions_on_the_dimension_layer(backend):
|
||||
}
|
||||
|
||||
|
||||
async def test_draw_chains_text_follows_the_plot_scale_and_dimscale_is_put_back(backend):
|
||||
# At the style's DIMSCALE 1 a 1:50 chain's 2.5 text was 2.5 drawing units -
|
||||
# 0.05 mm on the sheet - and the README plan render showed empty boxes where
|
||||
# the numbers belong. The chain carries the scale; the drawing keeps its own.
|
||||
shell = Wall(
|
||||
id="shell",
|
||||
axis=((0.0, 0.0), (8200.0, 0.0), (8200.0, 6200.0), (0.0, 6200.0)),
|
||||
thickness=200.0,
|
||||
closed=True,
|
||||
)
|
||||
before = await backend.system_get_variable("DIMSCALE")
|
||||
result = await draw_chains(backend, [shell], [], sides=("bottom",), scale=50)
|
||||
assert result["dimscale"] == 50.0
|
||||
heights = set()
|
||||
for handle in result["handles"]:
|
||||
block = backend._doc.entitydb[handle].get_geometry_block()
|
||||
heights |= {round(e.dxf.char_height, 6) for e in block if e.dxftype() == "MTEXT"}
|
||||
assert heights == {125.0} # 2.5 mm on paper x 50
|
||||
assert await backend.system_get_variable("DIMSCALE") == before
|
||||
|
||||
|
||||
async def test_assign_tags_numbers_each_kind_in_the_chosen_language():
|
||||
ops = [
|
||||
Opening(id="1", wall="a", kind="door", offset=0.0, width=900.0),
|
||||
|
||||
@@ -158,12 +158,3 @@ def test_competitor_reports_carry_no_result_for_the_new_tasks(filename):
|
||||
"result here would be fabricated"
|
||||
)
|
||||
assert report.get("matrix", "v2") == "v2"
|
||||
|
||||
|
||||
def test_the_published_reference_report_covers_the_whole_v3_matrix():
|
||||
report = json.loads((PUBLISHED / "autocad-mcp-pro.json").read_text(encoding="utf-8"))
|
||||
scored = {item["task_id"] for item in report["results"]}
|
||||
|
||||
assert scored == {task.task_id for task in TASKS_V3}
|
||||
assert report["matrix"] == "v3"
|
||||
assert report["summary"]["score"] == 100.0
|
||||
|
||||
@@ -49,7 +49,7 @@ def test_correctness_suite_has_the_settings_checks():
|
||||
):
|
||||
assert CHECKS[name][1] == "Settings"
|
||||
# v4 left the suite at 32; tracks B + G grew it to 39 (tests/test_benchmark_v5.py).
|
||||
assert len(CHECKS) == 43
|
||||
assert len(CHECKS) == 48
|
||||
|
||||
|
||||
@pytest.mark.asyncio # asyncio_mode is strict; the four tests above are sync
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_the_suite_carries_the_mechanical_and_sheet_checks():
|
||||
assert CHECKS[name][1] == "Mechanical"
|
||||
assert CHECKS["sheet_frame_iso5457_a3"][1] == "Sheet"
|
||||
assert CHECKS["bom_balloon_link"][1] == "Sheet"
|
||||
assert len(CHECKS) == 43
|
||||
assert len(CHECKS) == 48
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_the_suite_carries_the_four_architectural_checks():
|
||||
"arch_rooms_detect_foreign",
|
||||
):
|
||||
assert CHECKS[name][1] == "Architecture"
|
||||
assert len(CHECKS) == 43
|
||||
assert len(CHECKS) == 48
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""The v7 task matrix: v6 plus what track H added.
|
||||
|
||||
``takeoff_roundtrip`` and ``understand_foreign`` are track H's evidence, both
|
||||
gated on the synthetic plant pair (tests/fixtures/plant_pair.py) whose truth
|
||||
is computed from the positions it placed. Each gate is shown to fail on a
|
||||
broken input - a gate that cannot fail is not evidence. The five correctness
|
||||
checks run here once too, so a regression fails the suite and not only the
|
||||
A/B report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
|
||||
from benchmarks.tasks_v6 import TASKS_V6
|
||||
from benchmarks.tasks_v7 import DEFAULT_MATRIX, MATRICES, NEW_TASKS_V7, TASKS_V7, task_by_id
|
||||
from tests.fixtures import plant_pair
|
||||
|
||||
UNDERSTANDING_CHECKS = (
|
||||
"scale_check_detects_schematic",
|
||||
"pipe_takeoff_rmst_exact",
|
||||
"cable_takeoff_roundup",
|
||||
"diff_detects_known_edits",
|
||||
"topology_known_defects",
|
||||
)
|
||||
|
||||
|
||||
def test_v7_extends_v6_without_touching_it():
|
||||
assert TASKS_V7[: len(TASKS_V6)] == TASKS_V6
|
||||
assert [t.task_id for t in NEW_TASKS_V7] == ["takeoff_roundtrip", "understand_foreign"]
|
||||
assert len(TASKS_V7) == 21
|
||||
assert MATRICES["v6"] == TASKS_V6 and MATRICES["v7"] == TASKS_V7
|
||||
assert DEFAULT_MATRIX == "v7"
|
||||
assert task_by_id("takeoff_roundtrip").category == "plant"
|
||||
assert task_by_id("understand_foreign").category == "understand"
|
||||
|
||||
|
||||
def test_the_published_reference_report_covers_the_whole_current_matrix():
|
||||
# The release's reference run: every task of the current matrix, all passed.
|
||||
# It replaces 1.5's v3 run; the competitor reports stay the v2 runs.
|
||||
published = Path(__file__).resolve().parents[1] / "benchmarks" / "results" / "published"
|
||||
report = json.loads((published / "autocad-mcp-pro.json").read_text(encoding="utf-8"))
|
||||
scored = {item["task_id"] for item in report["results"]}
|
||||
|
||||
assert scored == {task.task_id for task in TASKS_V7}
|
||||
assert report["matrix"] == DEFAULT_MATRIX == "v7"
|
||||
assert report["summary"]["score"] == 100.0
|
||||
assert {item["status"] for item in report["results"]} == {"pass"}
|
||||
|
||||
|
||||
def test_the_adapter_implements_every_v7_task():
|
||||
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
|
||||
|
||||
for task in TASKS_V7:
|
||||
assert hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}"), task.task_id
|
||||
|
||||
|
||||
def test_the_suite_carries_the_five_understanding_checks():
|
||||
from benchmarks.correctness_suite import CHECKS
|
||||
|
||||
for name in UNDERSTANDING_CHECKS:
|
||||
assert CHECKS[name][1] == "Understanding"
|
||||
assert len(CHECKS) == 48
|
||||
|
||||
|
||||
async def _run(task_id, tmp_path):
|
||||
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
|
||||
|
||||
adapter = AutoCADMCPProAdapter(backend="ezdxf")
|
||||
await adapter.setup(tmp_path)
|
||||
try:
|
||||
await adapter._reset()
|
||||
return await getattr(adapter, f"_task_{task_id}")()
|
||||
finally:
|
||||
await adapter.cleanup()
|
||||
|
||||
|
||||
def _broken(edit):
|
||||
"""build_plant_pair, then ``edit(truth)`` on the files it wrote."""
|
||||
real = plant_pair.build_plant_pair
|
||||
|
||||
def build(directory):
|
||||
truth = real(directory)
|
||||
edit(truth)
|
||||
return truth
|
||||
|
||||
return build
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeoff_roundtrip_passes_headlessly(tmp_path):
|
||||
passed, metrics, _artifacts = await _run("takeoff_roundtrip", tmp_path)
|
||||
assert passed, metrics
|
||||
assert metrics["scale_verdict"] == "schematic"
|
||||
assert metrics["runs_checked"] == 9 and metrics["loads_checked"] == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeoff_roundtrip_fails_loudly_without_the_wiring_callouts(tmp_path, monkeypatch):
|
||||
"""Every callout gone from the layout: no load has a panel any more, so no
|
||||
cable length can be measured - and the gate names all four loads, while the
|
||||
pipe half, untouched, still agrees."""
|
||||
|
||||
def drop_callouts(truth):
|
||||
doc = ezdxf.readfile(truth["layout"])
|
||||
msp = doc.modelspace()
|
||||
for entity in list(msp.query("MULTILEADER")):
|
||||
msp.delete_entity(entity)
|
||||
doc.saveas(truth["layout"])
|
||||
|
||||
monkeypatch.setattr(plant_pair, "build_plant_pair", _broken(drop_callouts))
|
||||
passed, metrics, _artifacts = await _run("takeoff_roundtrip", tmp_path)
|
||||
assert passed is False
|
||||
assert sorted(row["tag"] for row in metrics["cable_mismatch"]) == ["M11", "M12", "T102", "T202"]
|
||||
assert metrics["pipe_mismatch"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_understand_foreign_passes_headlessly(tmp_path):
|
||||
passed, metrics, _artifacts = await _run("understand_foreign", tmp_path)
|
||||
assert passed, metrics
|
||||
assert metrics["clusters"] == 2 and metrics["misread_layers"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_understand_foreign_fails_loudly_when_the_units_tell_the_truth(tmp_path, monkeypatch):
|
||||
"""The same layout declaring millimetres: there is no disagreement left to
|
||||
flag, so the units gate fails - and only that gate."""
|
||||
|
||||
def honest_units(truth):
|
||||
doc = ezdxf.readfile(truth["layout"])
|
||||
doc.header["$INSUNITS"] = 4
|
||||
doc.saveas(truth["layout"])
|
||||
|
||||
monkeypatch.setattr(plant_pair, "build_plant_pair", _broken(honest_units))
|
||||
passed, metrics, _artifacts = await _run("understand_foreign", tmp_path)
|
||||
assert passed is False
|
||||
assert metrics["units_flagged"] is False
|
||||
assert metrics["outlier_found"] and metrics["copies_separated"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("name", UNDERSTANDING_CHECKS)
|
||||
async def test_each_understanding_check_passes(name):
|
||||
from benchmarks.correctness_suite import CHECKS
|
||||
|
||||
check, _category = CHECKS[name]
|
||||
assert await check() is True
|
||||
@@ -10,6 +10,7 @@ Strategy:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
@@ -21,6 +22,13 @@ import pytest
|
||||
from backends.base import normalize_lineweight
|
||||
from backends.com_backend import ComBackend
|
||||
|
||||
#: A path that marshals a point or an object array builds a real pywin32 VARIANT,
|
||||
#: and pywin32 exists only on Windows. The Windows lane runs these; elsewhere
|
||||
#: they skip, like the suite's other VARIANT-dependent COM tests.
|
||||
needs_pywin32 = pytest.mark.skipif(
|
||||
importlib.util.find_spec("win32com") is None, reason="pywin32 not installed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_document_switch_clears_com_document_state(monkeypatch):
|
||||
@@ -1312,6 +1320,7 @@ def _square(x0, y0, size):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@needs_pywin32
|
||||
async def test_a_chain_of_lines_becomes_one_closed_polyline_loop(island_seat):
|
||||
"""A flattened bore is 180 line edges; one temporary polyline carries them
|
||||
in a single COM call and is deleted once the hatch has evaluated it."""
|
||||
@@ -1327,6 +1336,7 @@ async def test_a_chain_of_lines_becomes_one_closed_polyline_loop(island_seat):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@needs_pywin32
|
||||
async def test_arcs_stay_true_arcs_in_radians_and_clockwise_is_swapped(island_seat):
|
||||
"""Curved-edge fidelity is the reason this tool exists: an arc edge becomes
|
||||
an AcDbArc, never a chord chain. ActiveX arcs run counter-clockwise, so a
|
||||
@@ -1355,6 +1365,7 @@ async def test_arcs_stay_true_arcs_in_radians_and_clockwise_is_swapped(island_se
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@needs_pywin32
|
||||
async def test_a_loop_autocad_refuses_is_taken_back_out(island_seat):
|
||||
"""AutoCAD answers an open loop with E_FAIL (measured); the temporary
|
||||
entities are deleted and the call says nothing was added."""
|
||||
|
||||
@@ -190,6 +190,9 @@ async def test_save_to_the_document_own_path_is_not_a_collision(backend, tmp_pat
|
||||
assert (await backend.drawing_save(str(path)))["ok"] is True
|
||||
await backend.entity_create_circle(0, 0, 3)
|
||||
# A spelling that differs from the key still names the same file.
|
||||
# POSIX resolves sub/.. through the directory, so it must exist; Windows
|
||||
# normalises the spelling before it looks.
|
||||
(tmp_path / "sub").mkdir()
|
||||
spelled = str(tmp_path / "sub" / ".." / "self.dxf")
|
||||
assert (await backend.drawing_save_as(spelled))["ok"] is True
|
||||
assert len(await backend.document_list()) == 2
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""scripts/field_test_takeoff.py never writes into the repository and never prints a quantity.
|
||||
|
||||
The script exists for client drawings, which never enter this repository
|
||||
(spec §14). It is exercised here on the synthetic plant pair only - the field
|
||||
test itself is never run in CI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _script():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"field_test_takeoff", ROOT / "scripts" / "field_test_takeoff.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_an_output_folder_inside_the_repository_is_refused_before_anything_runs(tmp_path, capsys):
|
||||
truth = build_plant_pair(tmp_path)
|
||||
target = ROOT / "field_test_output_must_not_exist"
|
||||
code = _script().main(
|
||||
["--pid", truth["pid"], "--layout", truth["layout"], "--out", str(target)]
|
||||
)
|
||||
assert code == 2
|
||||
assert not target.exists()
|
||||
assert "refused" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_a_missing_drawing_is_refused(tmp_path, capsys):
|
||||
code = _script().main(
|
||||
["--pid", str(tmp_path / "none.dxf"), "--layout", str(tmp_path / "none.dxf")]
|
||||
)
|
||||
assert code == 2
|
||||
assert "does not exist" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_the_report_lands_next_to_the_pid_and_the_console_carries_no_quantity(tmp_path, capsys):
|
||||
truth = build_plant_pair(tmp_path)
|
||||
code = _script().main(["--pid", truth["pid"], "--layout", truth["layout"]])
|
||||
assert code == 0
|
||||
summary = json.loads(capsys.readouterr().out)
|
||||
assert summary["scale_verdict"] == "schematic"
|
||||
assert Path(summary["report"]).parent == tmp_path
|
||||
assert all(Path(path).parent == tmp_path for path in summary["workbooks"])
|
||||
printed = json.dumps({k: v for k, v in summary.items() if k not in ("report", "workbooks")})
|
||||
for run in truth["pipe_runs"]:
|
||||
if run["layout_length_mm"] is not None:
|
||||
assert f"{run['layout_length_mm']:g}" not in printed, run["id"]
|
||||
for row in truth["cable_rows"]:
|
||||
assert f"{row['length_mm']:g}" not in printed, row["tag"]
|
||||
report = json.loads(Path(summary["report"]).read_text(encoding="utf-8"))
|
||||
assert report["scale"]["verdict"] == "schematic"
|
||||
|
||||
|
||||
def test_show_prints_the_rows_on_request(tmp_path, capsys):
|
||||
truth = build_plant_pair(tmp_path)
|
||||
code = _script().main(["--pid", truth["pid"], "--layout", truth["layout"], "--show"])
|
||||
assert code == 0
|
||||
assert '"cable"' in capsys.readouterr().out
|
||||
@@ -22,6 +22,11 @@ from backends.contracts.refs import (
|
||||
)
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
#: A path that marshals a point or an object array builds a real pywin32 VARIANT,
|
||||
#: and pywin32 exists only on Windows. The Windows lane runs these; elsewhere
|
||||
#: they skip, like the suite's other VARIANT-dependent COM tests.
|
||||
needs_pywin32 = pytest.mark.skipif(find_spec("win32com") is None, reason="pywin32 not installed")
|
||||
|
||||
TRACK_G_KEYS = ("dwg_write", "xref_live", "xlsx_write")
|
||||
|
||||
|
||||
@@ -385,6 +390,7 @@ class _ComDoc:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@needs_pywin32
|
||||
async def test_com_attach_calls_the_measured_member_with_radians(monkeypatch, external):
|
||||
import math
|
||||
|
||||
@@ -478,6 +484,7 @@ async def test_com_attach_refuses_a_plain_block_of_the_same_name_case_insensitiv
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@needs_pywin32
|
||||
async def test_com_attach_still_works_when_no_block_of_that_name_exists(monkeypatch, external):
|
||||
import backends.com_backend as cb
|
||||
|
||||
@@ -489,6 +496,7 @@ async def test_com_attach_still_works_when_no_block_of_that_name_exists(monkeypa
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@needs_pywin32
|
||||
async def test_com_raster_calls_addraster_with_radians(monkeypatch, tmp_path):
|
||||
import math
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ def _rendered(backend, handle):
|
||||
# ── headless engine ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_new_drawing_writes_standard_in_arial_like_acadiso(backend):
|
||||
# AutoCAD 2026's acadiso.dwt ships Standard as Arial. ezdxf's own default is
|
||||
# txt.shx, which has no superscript two: an architectural area label 'm²'
|
||||
# read 'm?' in AutoCAD and rendered as a box headlessly.
|
||||
rows = {row["name"]: row["font"] for row in await backend.textstyle_list()}
|
||||
assert rows["Standard"] == "arial.ttf"
|
||||
|
||||
|
||||
async def test_dimstyle_list_reports_standard_current_with_the_seventeen_values(backend):
|
||||
rows = await backend.dimstyle_list()
|
||||
assert [r["name"] for r in rows] == ["Standard"]
|
||||
|
||||
@@ -28,6 +28,49 @@ async def test_ezdxf_table_returns_composite_contract(backend):
|
||||
assert result.properties["bounds"] == {"min": [10.0, 72.0], "max": [30.0, 100.0]}
|
||||
|
||||
|
||||
async def test_ezdxf_table_title_row_is_merged_across_the_table(backend):
|
||||
# AutoCAD's TABLE merges its title row. Drawn as one column's cell, a title
|
||||
# such as 'DOOR SCHEDULE' wrapped inside the first column and ran over the
|
||||
# header row below it. The title spans the table, and only the table's outer
|
||||
# edges cross its row.
|
||||
result = await backend.entity_create_table(
|
||||
0,
|
||||
100,
|
||||
rows=[["D1", "900"]],
|
||||
headers=["TAG", "WIDTH"],
|
||||
column_widths=[20, 30],
|
||||
row_height=10,
|
||||
text_height=2.5,
|
||||
title="DOOR SCHEDULE",
|
||||
)
|
||||
children = [backend._doc.entitydb[h] for h in result.properties["child_handles"]]
|
||||
(title,) = [e for e in children if e.dxftype() == "MTEXT" and e.text == "DOOR SCHEDULE"]
|
||||
assert title.dxf.width == pytest.approx(50.0 - 2 * 1.0) # padding min(1.0, 10 x 0.15)
|
||||
verticals = [e for e in children if e.dxftype() == "LINE" and e.dxf.start.x == e.dxf.end.x]
|
||||
inner = [v for v in verticals if 0.0 < v.dxf.start.x < 50.0]
|
||||
assert inner and all(max(v.dxf.start.y, v.dxf.end.y) == pytest.approx(90.0) for v in inner)
|
||||
outer = [v for v in verticals if v.dxf.start.x in (0.0, 50.0)]
|
||||
assert len(outer) == 2 and all(max(v.dxf.start.y, v.dxf.end.y) == 100.0 for v in outer)
|
||||
|
||||
|
||||
async def test_ezdxf_table_cell_margin_scales_with_the_lettering(backend):
|
||||
# A 1:50 schedule letters at 125 in rows of 350. A flat 1.0 margin is 0.02 mm
|
||||
# on that sheet: the text sat on the cell line, and 'LEFT' read as 'FT'.
|
||||
result = await backend.entity_create_table(
|
||||
0,
|
||||
0,
|
||||
rows=[["D1", "LEFT"]],
|
||||
column_widths=[750, 1000],
|
||||
row_height=350,
|
||||
text_height=125,
|
||||
)
|
||||
children = [backend._doc.entitydb[h] for h in result.properties["child_handles"]]
|
||||
(left,) = [e for e in children if e.dxftype() == "MTEXT" and e.text == "LEFT"]
|
||||
assert left.dxf.insert.x == pytest.approx(750 + 50.0) # 0.4 x 125
|
||||
assert left.dxf.insert.y == pytest.approx(-50.0)
|
||||
assert left.dxf.width == pytest.approx(1000 - 2 * 50.0)
|
||||
|
||||
|
||||
async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path):
|
||||
result = await backend.entity_create_table(0, 30, rows=[["A", "1"]], headers=["Name", "Qty"])
|
||||
path = tmp_path / "table.dxf"
|
||||
|
||||
@@ -120,3 +120,25 @@ async def test_profile_switch_is_idempotent():
|
||||
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
|
||||
lean_again = await server._apply_tool_profile("lean")
|
||||
assert lean_again["enabled_count"] == len(server.LEAN_TOOL_NAMES)
|
||||
|
||||
|
||||
async def test_reapplying_a_profile_replaces_its_visibility_instead_of_stacking(monkeypatch):
|
||||
# fastmcp 3 appends a Visibility transform on every enable/disable call, and a
|
||||
# tool lookup walks the whole stack as nested calls. The lifespan re-applies
|
||||
# the profile on every client connection, so the stack grew by two per
|
||||
# session: after a few hundred in-process sessions every tool call raised
|
||||
# RecursionError (Linux CI, CPython 3.11, which counts C frames too).
|
||||
await server._apply_tool_profile("full")
|
||||
depth = len(server.mcp.transforms)
|
||||
for _ in range(300):
|
||||
await server._apply_tool_profile("lean")
|
||||
await server._apply_tool_profile("full")
|
||||
assert len(server.mcp.transforms) == depth
|
||||
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
monkeypatch.setattr(config.settings, "tool_profile", "lean")
|
||||
async with Client(server.mcp) as client:
|
||||
visible = {tool.name for tool in await client.list_tools()}
|
||||
status = (await client.call_tool("system_status", {})).structured_content
|
||||
assert visible == set(server.LEAN_TOOL_NAMES)
|
||||
assert status["backend"] == "ezdxf"
|
||||
|
||||
@@ -9,12 +9,20 @@ is dispatched at all).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import EntityInfo
|
||||
|
||||
#: A path that marshals a point or an object array builds a real pywin32 VARIANT,
|
||||
#: and pywin32 exists only on Windows. The Windows lane runs these; elsewhere
|
||||
#: they skip, like the suite's other VARIANT-dependent COM tests.
|
||||
needs_pywin32 = pytest.mark.skipif(
|
||||
importlib.util.find_spec("win32com") is None, reason="pywin32 not installed"
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@@ -251,6 +259,7 @@ async def test_a_non_string_block_name_is_a_type_error_on_both_placing_tools(bac
|
||||
await backend.block_insert("", 0, 0)
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_block_ref_probes_the_block_table_before_insert_block(com):
|
||||
backend, document, space = com
|
||||
with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"):
|
||||
@@ -332,6 +341,7 @@ async def test_explode_refuses_a_non_insert_without_writing(backend):
|
||||
assert len(list(backend._msp())) == before
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_explode_deletes_attdefs_adds_text_per_attrib_and_deletes_the_reference(com):
|
||||
backend, document, space = com
|
||||
attdef = _FakeObject("AcDbAttributeDefinition", "D1", Constant=False)
|
||||
@@ -395,6 +405,7 @@ async def test_com_explode_refuses_a_non_insert_before_any_call(com):
|
||||
assert space.calls == []
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_explode_adds_the_text_to_the_owner_block_not_the_active_layout(com):
|
||||
"""ActiveX ``Explode()`` places the members in the owner space; the tag
|
||||
TEXT must land beside them, not in whatever tab is active."""
|
||||
@@ -456,6 +467,7 @@ async def test_com_explode_refuses_a_nested_reference_before_explode(com):
|
||||
assert dispatched == [] and space.calls == [] and ref.deleted is False
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_explode_carries_the_attrib_frame_and_style_onto_the_text(com):
|
||||
"""The COM twin of the mirrored-reference test. ``AddText`` builds a +Z
|
||||
TEXT; an ATTRIB reflected to ``Normal (0, 0, -1)`` has an OCS ``Rotation``
|
||||
@@ -541,6 +553,7 @@ async def test_com_explode_carries_the_attrib_frame_and_style_onto_the_text(com)
|
||||
}
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_explode_skips_frame_members_the_attrib_does_not_expose(com):
|
||||
"""A bare ATTRIB (the shape the earlier fakes use) still explodes: an
|
||||
absent optional member is skipped, never written as a guess, and a
|
||||
@@ -580,6 +593,7 @@ async def test_com_explode_skips_frame_members_the_attrib_does_not_expose(com):
|
||||
assert names == ["Rotation", "Alignment", "Layer"]
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_explode_turns_a_constant_attdef_into_text_instead_of_deleting_it(com):
|
||||
"""``GetAttributes()`` excludes constant attributes (``GetConstantAttributes()``
|
||||
lists them) and ``Explode()`` returns each as an ``AcDbAttributeDefinition``
|
||||
@@ -645,6 +659,7 @@ async def test_com_explode_turns_a_constant_attdef_into_text_instead_of_deleting
|
||||
], "the constant ATTDEF's placement, angle and alignment ride onto its TEXT"
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_explode_falls_back_to_the_constant_tag_list_when_constant_is_absent(com):
|
||||
"""An object that does not expose ``Constant`` is still recognised through
|
||||
``GetConstantAttributes()``; a value-less placeholder is still just deleted."""
|
||||
@@ -937,6 +952,7 @@ async def test_explode_of_a_single_cell_grid_is_still_an_ordinary_insert(backend
|
||||
assert [(await backend.entity_get(h)).type for h in result["inserted_handles"]] == ["CIRCLE"]
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com):
|
||||
"""``TextString`` of a multi-line attribute is one flattened string; the
|
||||
content is ``MTextAttributeContent`` and the box ``MTextBoundaryWidth``.
|
||||
@@ -1018,6 +1034,7 @@ async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com):
|
||||
assert ref.deleted is True
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_explode_multi_line_attrib_takes_the_frame_before_the_angle(com):
|
||||
backend, document, space = com
|
||||
note = _FakeObject(
|
||||
@@ -1689,6 +1706,7 @@ class _SpaceFailingSecondAddText(_FakeSpace):
|
||||
return super().AddText(text, point, height)
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_explode_undoes_a_burst_that_fails_after_explode(com):
|
||||
"""ActiveX ``Explode()`` leaves the reference in place, so a failure after
|
||||
it (here ``AddText`` on the second attribute; a constant ATTDEF whose
|
||||
@@ -1794,6 +1812,7 @@ async def test_set_attributes_refuses_by_tag_and_leaves_the_value_alone(backend)
|
||||
assert await backend.block_get_attributes(ref.handle) == {"TAG": "7"}
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_insert_refuses_before_insert_block_and_writes_numbers_as_text(com):
|
||||
backend, document, space = com
|
||||
space.attributes = {"TAG": ""}
|
||||
@@ -1876,6 +1895,7 @@ async def test_define_on_an_existing_layer_reports_nothing_created(backend):
|
||||
assert result["layers_created"] == [], "layer 0 always exists"
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_define_probes_layers_before_blocks_add(com):
|
||||
backend, document, space = com
|
||||
with pytest.raises(ValueError, match="'NOT_A_LAYER' do not exist"):
|
||||
@@ -1921,6 +1941,7 @@ async def test_define_treats_an_existing_layer_case_insensitively(backend):
|
||||
assert result["layers_created"] == []
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_define_creates_a_case_folded_layer_once(com):
|
||||
backend, document, space = com
|
||||
with pytest.raises(ValueError, match=r"layer\(s\) 'Alpha' do not exist"):
|
||||
@@ -1933,6 +1954,7 @@ async def test_com_define_creates_a_case_folded_layer_once(com):
|
||||
assert [name for name, _ in document.Blocks.blocks["PID_CASE"].calls] == ["AddLine", "AddLine"]
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_define_treats_an_existing_layer_case_insensitively(com):
|
||||
backend, document, space = com
|
||||
document.Layers.Add("PID-EQUIP")
|
||||
@@ -1941,6 +1963,7 @@ async def test_com_define_treats_an_existing_layer_case_insensitively(com):
|
||||
assert result["layers_created"] == [] and document.Layers.added == ["PID-EQUIP"]
|
||||
|
||||
|
||||
@needs_pywin32
|
||||
async def test_com_define_refuses_a_bad_base_point_before_blocks_add(com):
|
||||
backend, document, space = com
|
||||
with pytest.raises(TypeError, match="'base_y'"):
|
||||
|
||||
@@ -192,6 +192,34 @@ def test_without_a_layout_lengths_stay_empty_with_an_open_item(pair):
|
||||
assert all(row["panel"] is None for row in result["rows"])
|
||||
|
||||
|
||||
def test_a_load_no_callout_places_takes_the_nearest_panel_flagged(pair):
|
||||
# M82's 'Wiring to CP1' callout is gone. By default the nearest panel on the
|
||||
# layout is taken - CP-1 at hypot(13000, 2000) = 13.2 m, not CP-M88 at 23.1 m
|
||||
# - and the row says so; with panel_rule='stated' the panel stays open.
|
||||
pid, _layout = pair
|
||||
layout = snap([r for r in layout_records() if r.handle != "wM82"], 4, "layout.dxf")
|
||||
result = cable_rows(pid, layout)
|
||||
m82 = by_tag(result)["M82"]
|
||||
assert (m82["panel"], m82["panel_source"]) == ("CP-1", "nearest")
|
||||
assert m82["length_m"] * 1000.0 == pytest.approx(15000.0, abs=1e-6) # Manhattan
|
||||
assert m82["cable_m"] == 18
|
||||
assert {
|
||||
"tag": "M82",
|
||||
"item": "panel_inferred",
|
||||
"detail": "no wiring callout names a panel; CP-1 is the nearest panel on the layout, "
|
||||
"13.2 m away",
|
||||
} in result["open_items"]
|
||||
assert ("M82", "missing_panel") not in {(i["tag"], i["item"]) for i in result["open_items"]}
|
||||
assert result["method"]["panel_rule"] == "nearest"
|
||||
stated = cable_rows(pid, layout, panel_rule="stated")
|
||||
assert by_tag(stated)["M82"]["panel"] is None
|
||||
assert ("M82", "missing_panel") in {(i["tag"], i["item"]) for i in stated["open_items"]}
|
||||
with pytest.raises(
|
||||
ValueError, match="^panel_rule: 'closest' unknown; choose from nearest, stated"
|
||||
):
|
||||
cable_rows(pid, layout, panel_rule="closest")
|
||||
|
||||
|
||||
def test_a_tag_missing_from_the_layout_is_an_open_item(pair):
|
||||
pid, _layout = pair
|
||||
layout = snap(layout_records(skip=("M87",)), 4, "layout.dxf")
|
||||
|
||||
@@ -171,6 +171,54 @@ def test_a_room_label_inside_the_wall_faces_is_measured():
|
||||
assert report["room_faces"]["unlabelled"] == 1
|
||||
|
||||
|
||||
def frame(handle, x0, y0, x1, y1, layer="0"):
|
||||
return EntityRecord(
|
||||
handle=handle,
|
||||
type="LWPOLYLINE",
|
||||
layer=layer,
|
||||
space="Model",
|
||||
points=((x0, y0), (x1, y0), (x1, y1), (x0, y1)),
|
||||
closed=True,
|
||||
bbox=(x0, y0, x1, y1),
|
||||
)
|
||||
|
||||
|
||||
def test_a_room_drawn_as_a_frame_is_measured_by_its_frame():
|
||||
# A P&ID draws its rooms as closed frames on no wall layer. Each numbered
|
||||
# label takes the smallest frame around it that holds no other room label;
|
||||
# the sheet border (both rooms) and a box drawn tight around a label (under
|
||||
# ten text heights a side) are not rooms.
|
||||
records = [
|
||||
frame("B", 0.0, 0.0, 20000.0, 10000.0), # the border: both labels inside
|
||||
frame("F1", 1000.0, 1000.0, 9000.0, 9000.0), # room 1: 8000 x 8000
|
||||
frame("F2", 11000.0, 1000.0, 19000.0, 7000.0), # room 2: 8000 x 6000
|
||||
frame("K", 1900.0, 7900.0, 4100.0, 8400.0), # a box around room 1's label
|
||||
text("R1", "ROOM 1", 2000.0, 8000.0),
|
||||
text("R2", "ROOM 2", 12000.0, 6000.0),
|
||||
# an unnumbered mention inside room 2 ('hot room') does not deny its frame
|
||||
text("H", "HOT ROOM", 15000.0, 3000.0),
|
||||
]
|
||||
report = describe(snapshot(records))
|
||||
rooms = {room["number"]: room for room in report["rooms"]}
|
||||
assert rooms["1"]["source"] == rooms["2"]["source"] == "label+frame"
|
||||
assert rooms["1"]["face"]["area"] == pytest.approx(64_000_000.0, abs=EPS)
|
||||
assert rooms["2"]["face"]["area"] == pytest.approx(48_000_000.0, abs=EPS)
|
||||
# a frame is weaker evidence than a face of wall layers
|
||||
assert rooms["1"]["confidence"] == rooms["2"]["confidence"] == 0.8
|
||||
|
||||
|
||||
def test_a_cabinet_label_naming_its_room_is_not_a_room():
|
||||
# 'CONTROL CABINET / FILLING ROOM' labels the cabinet, not a room: it names
|
||||
# equipment and carries no room number. A numbered room named after its
|
||||
# equipment ('PUMP ROOM 3') is still a room.
|
||||
records = [
|
||||
text("C", "CONTROL CABINET FILLING ROOM", 1000.0, 1000.0),
|
||||
text("P", "PUMP ROOM 3", 5000.0, 1000.0),
|
||||
]
|
||||
report = describe(snapshot(records))
|
||||
assert [(room["number"], room["name"]) for room in report["rooms"]] == [("3", "PUMP")]
|
||||
|
||||
|
||||
def test_an_attribute_and_a_text_of_one_tag_are_one_occurrence_and_a_copy_is_two():
|
||||
assert parse_tag("T4100") == "T4100"
|
||||
records = [
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
|
||||
from engineering.understand.labels import (
|
||||
CYRILLIC_LOOKALIKES,
|
||||
is_panel_name,
|
||||
normalize_panel,
|
||||
parse_diameters,
|
||||
parse_electrical,
|
||||
@@ -31,6 +32,16 @@ def test_plain_decodes_the_percent_codes():
|
||||
assert plain("%%065") == "A"
|
||||
|
||||
|
||||
def test_plain_decodes_the_caret_notation_of_a_text_entity():
|
||||
# A TEXT or ATTRIB stores its control characters in caret notation: ^J a line
|
||||
# break, ^I a tab, "^ " a caret. Left undecoded, "6^JROOM" is one word and a
|
||||
# room label's number reads as "6^JROOM".
|
||||
assert plain("6^JROOM") == "6\nROOM"
|
||||
assert plain("A^IB") == "A\tB"
|
||||
assert plain("x^ y") == "x^y"
|
||||
assert plain("\\S1^2;") == "1/2" # an MTEXT stack keeps its separator
|
||||
|
||||
|
||||
def test_plain_decodes_a_unicode_escape():
|
||||
assert plain("\\U+2205 51") == "\u2205 51"
|
||||
assert plain("\\u+00D8") == "\u00d8"
|
||||
@@ -202,6 +213,25 @@ def test_wiring_callouts_in_four_languages():
|
||||
assert wiring_target("CP 2 panosuna") == "CP-2"
|
||||
|
||||
|
||||
def test_a_panel_name_is_told_by_its_letters():
|
||||
# normalize_panel reads any letters-and-digits token; only a panel prefix
|
||||
# (control panel, junction box, distribution board, MCC ...) makes a panel
|
||||
assert is_panel_name("CP-1") and is_panel_name("JB-2") and is_panel_name("CP-M82")
|
||||
assert is_panel_name("MCC-3") and is_panel_name("DB-12")
|
||||
assert not is_panel_name("M-87") and not is_panel_name("T-4100")
|
||||
assert not is_panel_name(None) and not is_panel_name("")
|
||||
|
||||
|
||||
def test_the_russian_to_before_a_panel_is_a_wiring_callout():
|
||||
# A Russian P&ID closes a load's text with the panel it is wired to: 'к CP1'
|
||||
# ('to CP1'), or 'к щиту CP-2' ('to switchboard CP-2'). The unit кВт holds
|
||||
# the same letter and is no preposition.
|
||||
assert wiring_target("к CP1") == "CP-1"
|
||||
assert wiring_target("P = 2,2 кВт\nк CP1") == "CP-1"
|
||||
assert wiring_target("к щиту CP-2") == "CP-2"
|
||||
assert wiring_target("P = 2,2 кВт") is None
|
||||
|
||||
|
||||
def test_a_callout_without_a_wiring_phrase_names_no_panel():
|
||||
assert wiring_target("CP1") is None
|
||||
assert wiring_target("Wiring to") is None
|
||||
|
||||
@@ -190,3 +190,30 @@ def test_the_synthetic_pid_is_schematic_against_its_layout(tmp_path):
|
||||
assert len(result["matched"]) >= 3
|
||||
# The layout's two plan copies: the check must pick one of them.
|
||||
assert result["scope"]["cluster_b"] is not None
|
||||
|
||||
|
||||
def test_a_wiring_callout_is_not_a_second_place_for_its_panel():
|
||||
"""``Wiring to CP-2`` names the panel it points at; it is not written at the
|
||||
panel. Read as a tag it would make every panel with a callout ambiguous
|
||||
(and ``Wiring to CP1`` a phantom tag ``CP1``), so the callout takes no part
|
||||
- the same rule the line-network reader (`network.tag_occurrences`) keeps."""
|
||||
plan = {**PLAN, "CP-2": (3000.0, 15000.0)}
|
||||
callouts = [
|
||||
text("W1", "Wiring to CP-2", 12000.0, 0.0),
|
||||
text("W2", "Wiring to CP1", 0.0, 9000.0),
|
||||
]
|
||||
result = scale_check(snapshot(tags(plan, "A")), snapshot(tags(plan, "B") + callouts))
|
||||
assert result["ambiguous"] == []
|
||||
assert "CP-2" in {row["tag"] for row in result["matched"]}
|
||||
assert result["only_b"] == []
|
||||
assert result["pair_count"] == math.comb(len(plan), 2)
|
||||
|
||||
|
||||
def test_the_synthetic_pair_reproduces_the_generators_own_statistics(tmp_path):
|
||||
"""The generator computes its truth from the positions it placed: 11 shared
|
||||
tags, 55 pairs, 21.8 % within +/-10 % of the median. The check must see the
|
||||
same tags - a wiring callout counted as a panel would drop to 45."""
|
||||
truth = build_plant_pair(tmp_path)
|
||||
result = scale_check(read_snapshot(truth["pid"]), read_snapshot(truth["layout"]))
|
||||
assert result["pair_count"] == truth["scale"]["overall"]["pairs"] == 55
|
||||
assert result["within_10pct"] == pytest.approx(truth["scale"]["overall"]["within_10pct"])
|
||||
|
||||
@@ -240,6 +240,134 @@ def test_a_run_with_a_free_end_is_c(pair):
|
||||
assert reasons == {"end_not_on_equipment"}
|
||||
|
||||
|
||||
def test_a_layout_takeoff_says_how_much_it_could_not_route(pair):
|
||||
# the free-ended run is not routed: the warning names the count, the drawn
|
||||
# length it leaves to the control rows and the P&ID alternative
|
||||
pid, layout, _network = pair
|
||||
records = pid_records() + [rec("L3", "LINE", PIPE, ((0.0, 600.0), (0.0, 1600.0)))]
|
||||
drawing = snap(records, 4, "pid.dxf")
|
||||
result = pipe_rows(
|
||||
build_network(drawing, layers=[PIPE]), drawing, layout, length_source="layout"
|
||||
)
|
||||
(warning,) = [w for w in result["warnings"] if "could not be routed" in w]
|
||||
assert warning.startswith("1 of 3 runs could not be routed on the layout")
|
||||
assert "1.0 m drawn on the P&ID" in warning # 1000 mm
|
||||
assert "length_source='pid'" in warning
|
||||
|
||||
|
||||
def test_pid_lengths_need_no_equipment_at_the_ends():
|
||||
# A product line that ends in the open - no tag, no equipment - still has a
|
||||
# drawn length: measured on the P&ID it is a row, and each piece goes to the
|
||||
# P&ID room it lies in (the wall at x = 4000 cuts the second line).
|
||||
walls = [
|
||||
rec(
|
||||
"W",
|
||||
"LWPOLYLINE",
|
||||
WALLS,
|
||||
((0, 0), (8000, 0), (8000, 4000), (0, 4000)),
|
||||
closed=True,
|
||||
bbox=(0.0, 0.0, 8000.0, 4000.0),
|
||||
),
|
||||
rec("X", "LINE", WALLS, ((4000, 0), (4000, 4000))),
|
||||
text("R1", "ROOM 101", (1000.0, 3000.0), 250.0),
|
||||
text("R2", "ROOM 102", (5000.0, 3000.0), 250.0),
|
||||
]
|
||||
pipes = [
|
||||
rec("p1", "LINE", PIPE, ((1000.0, 1000.0), (3000.0, 1000.0))), # 2000, room 101
|
||||
rec("p2", "LINE", PIPE, ((3000.0, 1000.0), (6000.0, 1000.0))), # 1000 + 2000
|
||||
text("d", "Ø51", (2000.0, 1100.0), 50.0),
|
||||
]
|
||||
drawing = snap(walls + pipes, 4, "pid.dxf")
|
||||
result = pipe_rows(build_network(drawing, layers=[PIPE]), drawing, None, services=["product"])
|
||||
rows = {(row["room"], row["diameter"]): row for row in result["rows"]}
|
||||
assert rows[(room_key("ROOM 101"), "Ø51")]["net_m"] == pytest.approx(3.0, abs=EPS)
|
||||
assert rows[(room_key("ROOM 102"), "Ø51")]["net_m"] == pytest.approx(2.0, abs=EPS)
|
||||
assert result["totals"]["unroutable_m"] == 0.0
|
||||
(run,) = result["runs"]
|
||||
assert run["tags"] == [] and run["status"] in ("A", "B")
|
||||
|
||||
|
||||
def test_without_room_faces_a_piece_goes_to_the_nearest_numbered_room():
|
||||
# No walls: pieces go to the nearest room label. 'TO ROOM STORAGE' is a note
|
||||
# pointing at a room, not a room: when the drawing numbers its rooms, only
|
||||
# the numbered labels are candidates, so the line at x 8500-9500 is room 102.
|
||||
records = [
|
||||
text("R1", "ROOM 101", (0.0, 3000.0), 250.0),
|
||||
text("R2", "ROOM 102", (12000.0, 3000.0), 250.0),
|
||||
text("N", "TO ROOM STORAGE", (9000.0, 1200.0), 250.0),
|
||||
rec("p", "LINE", PIPE, ((8500.0, 1000.0), (9500.0, 1000.0))),
|
||||
text("d", "Ø51", (9000.0, 1100.0), 50.0),
|
||||
]
|
||||
drawing = snap(records, 4, "pid.dxf")
|
||||
result = pipe_rows(build_network(drawing, layers=[PIPE]), drawing, None, services=["product"])
|
||||
assert [(row["room"], row["net_m"]) for row in result["rows"]] == [
|
||||
(room_key("ROOM 102"), pytest.approx(1.0, abs=EPS))
|
||||
]
|
||||
|
||||
|
||||
def detail_pid():
|
||||
# three tanks spread over the plant, and a compact detail drawing of the same
|
||||
# three tanks at x = 60 m: the detail repeats their pipes
|
||||
return [
|
||||
*tank("A", "T4100", 0.0, 0.0),
|
||||
*tank("B", "T4101", 20000.0, 0.0),
|
||||
*tank("C", "T4102", 20000.0, 20000.0),
|
||||
rec("L1", "LINE", PIPE, ((500.0, 250.0), (20000.0, 250.0))), # 19.5 m
|
||||
rec("L2", "LINE", PIPE, ((20250.0, 500.0), (20250.0, 20000.0))), # 19.5 m
|
||||
*tank("a", "T4100", 60000.0, 0.0),
|
||||
*tank("b", "T4101", 61000.0, 0.0),
|
||||
*tank("c", "T4102", 61000.0, 1000.0),
|
||||
rec("L3", "LINE", PIPE, ((60500.0, 250.0), (61000.0, 250.0))), # 0.5 m
|
||||
rec("L4", "LINE", PIPE, ((61250.0, 500.0), (61250.0, 1000.0))), # 0.5 m
|
||||
text("D1", "Ø51", (10000.0, 260.0), 50.0),
|
||||
]
|
||||
|
||||
|
||||
def test_a_detail_drawn_twice_is_reported_and_left_out_on_request():
|
||||
drawing = snap(detail_pid(), 4, "pid.dxf")
|
||||
network = build_network(drawing, layers=[PIPE])
|
||||
counted = pipe_rows(network, drawing, None, services=["product"])
|
||||
(region,) = counted["detail_copies"]
|
||||
assert (region["id"], region["tags"]) == ("D1", ["T4100", "T4101", "T4102"])
|
||||
assert (region["kind"], region["base"]) == ("repeated", None)
|
||||
assert region["box"][0] > 55000.0 # the compact copy, not the spread-out plant
|
||||
assert counted["totals"]["net_m"] == pytest.approx(40.0, abs=EPS) # 19.5 + 19.5 + 0.5 + 0.5
|
||||
assert any("possible detail cop" in w and "D1" in w for w in counted["warnings"])
|
||||
left_out = pipe_rows(network, drawing, None, services=["product"], exclude_regions=["D1"])
|
||||
assert left_out["totals"]["net_m"] == pytest.approx(39.0, abs=EPS)
|
||||
assert left_out["totals"]["excluded_m"] == pytest.approx(1.0, abs=EPS)
|
||||
excluded = [c for c in left_out["control"] if c["kind"] == "excluded_region"]
|
||||
assert {c["reason"] for c in excluded} == {"D1"} and len(excluded) == 2
|
||||
with pytest.raises(ValueError, match="^exclude_regions: 'D9' unknown; the P&ID has D1"):
|
||||
pipe_rows(network, drawing, None, services=["product"], exclude_regions=["D9"])
|
||||
|
||||
|
||||
def test_a_package_of_one_equipments_parts_is_reported_as_a_region():
|
||||
# A skid drawn in detail: P200's own parts P200A / P200B / P200C sit close
|
||||
# together at x = 60 m, far from the plant's tanks. Their tags are written
|
||||
# once each - no copy - but the family is one package whose internal pipes
|
||||
# a site takeoff may leave to its vendor: reported, left out on request.
|
||||
records = [
|
||||
*tank("A", "T4100", 0.0, 0.0),
|
||||
*tank("B", "T4101", 20000.0, 0.0),
|
||||
rec("L1", "LINE", PIPE, ((500.0, 250.0), (20000.0, 250.0))), # 19.5 m
|
||||
*tank("p", "P200A", 60000.0, 0.0),
|
||||
*tank("q", "P200B", 61000.0, 0.0),
|
||||
*tank("r", "P200C", 61000.0, 1000.0),
|
||||
rec("L3", "LINE", PIPE, ((60500.0, 250.0), (61000.0, 250.0))), # 0.5 m
|
||||
rec("L4", "LINE", PIPE, ((61250.0, 500.0), (61250.0, 1000.0))), # 0.5 m
|
||||
]
|
||||
drawing = snap(records, 4, "pid.dxf")
|
||||
network = build_network(drawing, layers=[PIPE])
|
||||
counted = pipe_rows(network, drawing, None, services=["product"])
|
||||
(region,) = counted["detail_copies"]
|
||||
assert (region["kind"], region["base"]) == ("family", "P200")
|
||||
assert region["tags"] == ["P200A", "P200B", "P200C"]
|
||||
left_out = pipe_rows(network, drawing, None, services=["product"], exclude_regions=["D1"])
|
||||
assert left_out["totals"]["net_m"] == pytest.approx(19.5, abs=EPS)
|
||||
assert left_out["totals"]["excluded_m"] == pytest.approx(1.0, abs=EPS)
|
||||
|
||||
|
||||
def test_other_services_are_left_out_unless_asked(pair):
|
||||
pid, layout, network = pair
|
||||
assert DEFAULT_SERVICES == ("product", "cip_supply", "cip_return")
|
||||
|
||||
@@ -17,6 +17,7 @@ from engineering.understand.vocab import (
|
||||
classify_layer,
|
||||
equipment_kind,
|
||||
fold,
|
||||
is_network_layer,
|
||||
room_label,
|
||||
supply_return,
|
||||
)
|
||||
@@ -232,3 +233,31 @@ def test_block_names_say_their_kind(block, kind, language):
|
||||
def test_an_anonymous_block_has_no_kind():
|
||||
assert equipment_kind("*U12") is None
|
||||
assert equipment_kind(None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "network"),
|
||||
[
|
||||
("P_product piping", True),
|
||||
("P_cipsupplyline", True),
|
||||
("E_power", True),
|
||||
# symbols, not lines: the layer names the equipment drawn on it
|
||||
("KLEPPEN", False),
|
||||
("Valve_ball valves", False),
|
||||
("PRODUCT PUMPS", False),
|
||||
("CIP return pumps", False),
|
||||
("Storage tanks", False),
|
||||
("E_panel connections", False),
|
||||
# annotation on a piping layer: sizes, materials, numbers, hatching
|
||||
("P_pipe diameters", False),
|
||||
("PIPESIZES", False),
|
||||
("piping materials", False),
|
||||
("valve numbers", False),
|
||||
("PIPE TEXT", False),
|
||||
# not piping or electrical at all
|
||||
("WALLS", False),
|
||||
("0", False),
|
||||
],
|
||||
)
|
||||
def test_a_network_layer_holds_lines_not_symbols_or_annotation(name, network):
|
||||
assert is_network_layer(name) is network
|
||||
|
||||