Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
118 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
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
TOOL_PACKS gate so a mechanical-only client never pays for the P&ID
surface. Spec: docs/superpowers/specs/2026-09-15-v1.6-pid-design.md.
Track E of 1.6: the environment a drawing lives in. 38 tools in three new
sections — styles (18), page setup and templates (19), environment (20) —
the drawing_settings facade grown, a system-variable catalog, five bundled
templates, several open documents headlessly, portable layer states, named
views and UCS, and the live-only group (launch, preferences, document
properties, operator prompts) refused honestly headlessly. Spec:
docs/superpowers/specs/2026-09-16-v1.6-settings-design.md.
Tracks B and G of 1.6: the complete mechanical 2D part drawer and the sheet
standard. 25 tools in four new sections — mechanical parts (21), standard
parts (22), mechanical annotation (23) and sheet and delivery (24) — one part
model and one view engine instead of a zoo of per-part generators, a new
mech pack, six mech_* critique focuses and four new contract members
(xref_attach, xref_manage, image_attach, drawing_export_dwg). Spec:
docs/superpowers/specs/2026-09-22-v1.6-mech-design.md.
Track F of 1.6: architectural 2D, the plan model. 12 tools in a new SECTION
25 — a wall network with hosted doors and windows drawn by one engine (L / T /
X junctions resolved, openings cut, poché by material), rooms computed as the
faces the walls enclose and read back from any plan, schedules read off the
drawing, a furniture and sanitary catalogue, the structural grid and plan
symbols, exterior dimension chains and the whole plan in one transaction — a
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,textstyle_list/create/set_current,mleaderstyle_list/create,drawing_apply_standard. ISO-25 and ANSI (metric) presets are authored data pinned bytests/test_standards_presets.pyagainst ISO 129-1 / ASME Y14.2 (DIMTXT 2.5 / 3.0, DIMASZ 2.5 / 3.0, DIMEXO 0.625 / 1.5, DIMDSEP,/., DIMTAD 1 / 0 …).dimstyle_modifyaccepts the 33-name whitelist only (the 17 preset variables plus 16 more —DIM_VARIABLE_WHITELIST) and refuses an out-of-range value by name before writing;changedlists what moved. A font neither bundled nor on the engine's font path is written and reportedfont_resolved: false, never refused.mleaderstyle_createworks on both engines: ActiveX exposes no MLeaderStyle collection, but theACAD_MLEADERSTYLEdictionary holds fullIAcadMLeaderStyleobjects, so the live engine creates the style throughAddObjectand the property writes — nomleaderstylecapability key exists. -
Page setup & templates — 6 tools, pack
core.page_setup_list/apply(PAPER_SIZESISO A0–A4 / ANSI A–E,CTB_CATALOG,SCALES),plot_style_list(catalog + installed),batch_plot—mediabox_mmparsed from each PDF's/MediaBox, soISO_A3landscape is 420 × 297 because the file says so —drawing_template_list,drawing_template_save(.dwtrefused headlessly, capabilitydwt_write). Five bundled templates built byscripts/build_templates.pythrough the server's own methods (--checkproves the DXFs byte-reproducible); their.dwttwins were built once on AutoCAD 2026 by the smoke.drawing_new(template="iso_a3_mech")resolves the bundled file and reports{name, source}. -
Environment — 22 tools, pack
settings.document_list/activate/closeon both engines (the headless backend keeps a document registry;drawing_closeis the active-document case);layer_state_save/restore/ list/deleteas portable XRECORD snapshots — restore applies only the requestedproperties, reportsmissing_layers(skipped, never created) andnew_layers(untouched), and the state survives save/reopen;view_named_save/restore/list;ucs_list/set/restore(non-orthogonal axes refused with the measured angle; tool coordinates stay WCS);system_variable_describeover a 90-entry catalog (known: false+ nearest names on a miss);drawing_properties_get/set(custom properties on both engines, summary fields COM-only:dwgprops);system_launch(live_application),system_preferences_get/set(whitelisted keys with ranges, read-only keys refused by name,old/newreported:preferences),user_pick_point/selectandsystem_prompt_message(interactive_prompt; a cancelled pick is{cancelled: true}, aCOM_CALL_TIMEOUToverrun istimed_out). -
drawing_settingsgrows bylimits,grid/grid_spacing,snap/snap_spacing,ortho,polar/polar_angle,psltscale,annotation_scale,linear_units,angular_units,dimstyle,textstyle;system_set_variablerefuses an out-of-range value for a catalogued name before writing. -
Benchmark evidence.
benchmarks/tasks_v4.pyaddspage_setup_truth; the correctness suite grows to 32 withsettings_dimstyle_iso25_values,settings_layer_state_roundtrip,settings_pdf_mediabox_a3. A/B againstv1.5.1: 26 / 32 → 32 / 32, sixmiss → pass, zero regressed.scripts/smoke_settings_com.pyruns the track once against the live seat (record below). -
Benchmark evidence for tracks B and G.
benchmarks/tasks_v5.py(18 tasks, the default matrix; v4 / v3 / v2 stay addressable) addsmech_assembly: a flange-coupling A3 sheet built through the code the tools call — frame and title block, a hub with a full section and chain dimensions, a flange with its bolt-circle end view, ISO 4014 bolts and ISO 4032 nuts, the parts list read off the drawing and linked balloons — gated ondrawing_critique(focus=None)returning zero issues and a finalize score of at least 90 (it scores 100); a test drops one balloon and watches the gate fail by name. The correctness suite grows to 39 withmech_part_roundtrip,mech_section_hatch_area(1200 mm² read back out of the drawing),mech_iso286_on_dimension,mech_thread_unrepresented_is_caught,std_part_iso4014_m12,sheet_frame_iso5457_a3andbom_balloon_link. A/B againstv1.5.1: 26 / 39 → 39 / 39, thirteenmiss → pass, zero regressed. The token suite measures aTOOL_PACKS=core,mechrow: 193 tools / 61,673 tokens against 224 / 72,163 by default. -
Live COM smoke.
scripts/smoke_mech_com.pyexecuted once on AutoCAD 2026 (25.1s (LMS Tech)): the A3 frame and ISO 7200 title block, a two-step shaft with a full section and 12 chain dimensions, an ISO 4014 M12x60 block carrying 1 ACADMCP_MECH chunk, an ISO 7573 parts list read off the drawing with its linked balloon, an xref attached and detached, and a real 31687-byte.dwgwritten by ActiveXSaveAs—drawing_critique(focus=None)returned 0 issues (record below). -
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. The plan travels asACADMCP_ARCHXDATA through the mechanical codec, generalised to take an application id;draw_primstakes a role map. Thearchlayer set is ISO 13567-style with ISO 128 lineweights; wall poché followsengineering/arch/materials.py, whose rows each name their source. Areas are measured, never typed. Lean gains four and reaches 64. -
Benchmark evidence for track F.
benchmarks/tasks_v6.py(19 tasks, the default matrix; v5 to v2 stay addressable) addsarch_roundtrip: the example plan drawn byarch_plan_from_specand read back byarch_rooms_detectwithin 0.1 % of each label and of the hand-computed net floor, the schedules listing the drawn tags, gated on a zero-issuedrawing_critique(focus=None)and a finalize score of at least 90; a test removes one room label and watches the gate fail by name. The correctness suite grows to 43 witharch_junction_l_t_x,arch_room_area_net,arch_opening_cuts_wallandarch_rooms_detect_foreign. A/B againstv1.5.1: 26 / 43 → 43 / 43, seventeenmiss → pass, zero regressed. The token suite measures aTOOL_PACKS=core,archrow: 191 tools / 60,568 tokens against 236 / 78,772 by default. -
Live COM smoke.
scripts/smoke_arch_com.pyexecuted once on AutoCAD 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, anddrawing_critique(focus=None)returned 0 issues (record below). -
Understanding foreign drawings — 4 tools, pack
core; plant takeoffs — 2 tools, packplant.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 newofficeextra (openpyxl, also infull). Lean gainsdrawing_understandand reaches 65. -
Benchmark evidence for track H.
benchmarks/tasks_v7.py(21 tasks, the default matrix; v6 to v2 stay addressable) addstakeoff_roundtripandunderstand_foreign, both on the synthetic plant pair oftests/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 withscale_check_detects_schematic,pipe_takeoff_rmst_exact,cable_takeoff_roundup,diff_detects_known_editsandtopology_known_defects. A/B againstv1.5.1: 26 / 48 → 48 / 48, twenty-twomiss → pass, zero regressed. -
scripts/field_test_takeoff.pyruns 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--showis given. -
Live COM smoke.
scripts/smoke_understand_com.pyexecuted 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 / ISA-5.1 catalogue authored in code (11 valve bodies × 6 actuators, 6 rotating machines, 4 heat exchangers, 6 parametric vessels with nozzle ports, 7 inline items, the instrument bubble in 4 types × 5 locations, 5 line markers, 3 connectors), placed as real blocks with aTAGATTRIB, named ports, and anACADMCP_PIDXDATA payload. Ports are transformed through the INSERT's real rotation, scale and mirror; a stretched symbol (|x_scale| ≠ |y_scale|) has no circle for a radial port and is refused rather than answered with a wrong point.pid_line_draw: port-to-port orthogonal routing (autopicks from seven candidates by fewest bends, then length;direct; or waypoints), eight ISA-5.1 line classes mapped to layer / linetype / marker blocks, and line numbers filled from a format (default{size}-{service}-{seq}-{spec}-{insulation}; an empty field drops out with its separator). A route that cannot honour thestubon both ports is refused by name (route((0,0), 0°, (3,8), 180°, stub=5)raises) rather than drawn short; a non-axis port direction, a NaN coordinate and an unfillable format token are refused before anything is written. Lines it crosses are counted and reported (crossings), never silently drawn over — and never refused.pid_tag_parse: the ISA-5.1 identification-letter grammar (Table 4.1, pinned bytests/data/isa51_tags.json), with the equipment-prefix map.pid_graph: the reader. Every symbol INSERT becomes a node with asourceand aconfidence— catalogue name 1.0,ACADMCP_PIDpayload 0.95,TAG-style attribute or keyword block name 0.6 with inferred ports, an INSERT a plain line merely touches 0.3. Confidence is the minimum of its inputs, never raised by agreement between heuristics. Each line end resolves to a port, a junction on another line, ordanglingwith the nearest connectable port as a hint, always within its own space (scope="all"never joins two layouts). Edgelengthis the bulge-aware length the engine reports, not a chord walk (12.5 % short on a semicircular jump).pid_instrument_index/pid_line_list/pid_equipment_list: derived from the graph only, optionally written as CSV. An instrument tapping a pipe reports the pipe's line number, never its own signal wire.pid_from_spec: a whole sheet from one JSON spec in one transaction — rolled back entirely on the first refusal — returning the handles, the graph and the critique;dry_run=Trueroutes in memory and touches nothing.
-
block_define(name, entities, attdefs)on both engines: a block from typed primitive specs (line, circle, arc, polyline, text, solid) and ATTDEFs, no source entities needed;overwrite=Truereplaces the contents so existing INSERTs keep the name. Validation runs before any write: a DXF-illegal layer name, a duplicate or trailing-newline ATTDEF tag, a multi-line or trailing-caret string areTypeError/ValueErrornamingentities[i]/attdefs[i], and nothing is created. -
entity_get_xdata/entity_set_xdataon both engines, typed by DXF group code. The two limits AutoCAD enforces silently are enforced loudly and before the write: a string over 255 characters and an entity budget of 16 KB across every application (three 15 KB apps on one entity used to pass headlessly and would have failedSetXDatalive). LF/CR in a string is refused — it split a DXF tag, the saved file failed to reopen and, because every undo snapshot is a DXF save,transaction_rollbackbroke after one such write.nan/infare refused rather than written as literal text that reads back asnull. -
Six critique focuses —
pid_dangling_line,pid_duplicate_tag,pid_incompatible_connection,pid_untagged_instrument,pid_illegal_tag,pid_unconnected_equipment— run underfocus=Noneand insidedrawing_finalize, over one cached graph. They score evidence only: a drawing without a recognised P&ID node returns[], a 0.3 touch guess can neither create nor suppress a finding, and a line ending on a sheet frame (an INSERT the drawing does not vouch for) is dangling.drawing_refinehints name only portspid_line_drawaccepts. -
Two resources:
autocad://pid/symbols(the catalogue with ports and parameter schemas) andautocad://standards/isa51(the letter tables and the grammar's disambiguation rules). -
Benchmark evidence.
benchmarks/tasks_v4.pyaddspid_roundtrip(the example sheet drawn throughpid_from_spec, read back by a graph builder that never sees the spec); the correctness suite grows to 29 with three P&ID checks. A/B againstv1.5.1: 26 / 29 → 29 / 29, threemiss → pass, zero regressed (ab-v1.5.1-vs-v1.6.0-dev.json). -
scripts/smoke_pid_com.pyruns the track once against the live AutoCAD on the machine, in a new document;scripts/render_pid_catalog.pyandscripts/render_readme_pid.pyrebuild the two README images.
Fixed
-
Hatch islands on a live seat.
hatch_add_boundarywas 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 becomes a temporary LINE, true ARC or ELLIPSE appended withAppendInnerLoopand deleted afterEvaluate; a chain of lines is one polyline. Measured on AutoCAD 2026: a two-arc island leaves exactly 10000 - 100*pi of a 100 x 100 hatch, the OD40/ID20 sleeve's section B-B hatches 942.2864 mm2 (the view engine's declared 180-gon), and an open loop is refused with nothing left behind. -
block_find_referencesfound nothing on a live seat. ActiveX names a block referenceAcDbBlockReference, soentity_list(type_filter="INSERT")matched nothing. The COM type filter now lets the DXF name find it (INSERT,LWPOLYLINE, and every*DIMENSIONclass forDIMENSION); the reported type is unchanged. -
Found by the tracks B + G evidence (each with a test that fails on the old code):
dimension_diameterput its text beyond the second chord point headlessly and beyond the first on AutoCAD (AddDimDiametric's ChordPoint, measured: a ⌀50 from (x, 170) to (x, 220) annotated at (x, 160)). The two engines annotated opposite sides of the same part, and headlessly every front-view diameter ofmech_part_drawstood on its own chain dimension —dim_overlapfired on any single-step disk. The headless engine now follows ActiveX.- On a live seat
mech_part_drawdied after drawing every view: its anchor POINT goes onMECH, and ActiveX refusesentity.Layeron a missing layer (Key not found) where ezdxf creates it. Every mechanical draw path now ensures its target layers before the first entity. bom_extractreturned nothing live: ActiveX names a block referenceBLOCKREFERENCE, and the parts list filtered onINSERTonly. Both names are read.- The live
_entity_inforeported notext_positionfor a dimension (the entity arrives narrowed toIAcadEntity, which has noTextPosition), sodim_overlapcompared bounding-box centres — a diameter and its bore read "0.00 mm apart". It is read through the concrete class now. pid_graphraisedKeyError: 'start'insidedrawing_critique(focus=None)on a LINE whose point read AutoCAD had refused while busy; such a line is left out instead.- The validator's
fake_dimension_textread a standard-part designation (ISO 4014 - M12x60) as a hand-typed dimension, so every parts list drawn as text cost its sheet a warning. The designation is exempt; a measurement beside it still flags.
-
Track A hardening (
tests/test_track_a_hardening.py; the remaining review findings are filed with a disposition each indocs/analysis/track-a-review-backlog.md):entity_create_block_refof an undefined block name is refused before writing (it returned a handle to a dangling INSERT).block_explodekeeps ATTRIB text: each ATTRIB becomes a TEXT entity on both engines instead of being dropped.pid_from_specrefuses a NaN coordinate before the transaction opens and rolls back onBaseException, not onlyException.- A signal line no longer receives a fabricated line number and label; the
reader reports
line_number: nullfor it. - A bubble's exit point honours the first waypoint instead of the port direction.
- A foreign or hand-edited
ACADMCP_PIDpayload is read tolerantly, and an existing polyline with a bulge is tested as its arc, not its chord, so a line through a crossing jump counts as a crossing. block_insertattribute values are validated as strings; an int or None is aTypeErrornaming the tag, notstr()-coerced.block_definerefuses a NaN base point and a layer that does not exist in the drawing (it wrote both anddrawing_auditrepaired them later).
-
Headless
block_insert(attributes=…)lost its ATTRIBs. The ezdxf path usedadd_auto_blockref, which wraps the INSERT in an anonymous*Ublock: the handle it returned reportedblock_name*U1, andblock_get_attributeson it returned{}. Reproduce on v1.5.1 withblock_create_from_entitiesof a block with one ATTDEF, thenblock_insert(name, attributes={"TAG": "P-101"})andblock_get_attributes(handle). It now inserts the named block withadd_auto_attribs, so the INSERT carries the name and the ATTRIBs, on both engines alike.block_insertof an undefined name is refused before writing (it used to return a handle whose dangling INSERTdrawing_auditlater deleted). -
Live LWPOLYLINEs report as
POLYLINEthrough ActiveX (AcDbPolyline), which the P&ID line reader's type filter excluded: on AutoCAD every auto-built line number restarted at seq 1 andcrossingswas always 0. Both spellings are accepted; the COM_entity_infopolyline branch now returnspoints(and per-vertexbulges) for a live LWPOLYLINE. -
Polyline
bulgesnow leave both engines in WCS: a mirrored polyline's arcs were handed out in the entity frame next to WCS points, so a reader tested them on the wrong side of the chord. -
INSERT properties gain
geometry_bbox(drawn geometry only, ATTRIBs excluded) andmirroredon both engines. The COM box is exact at right angles and measured off them; a member ActiveX cannot measure is carried by its corners and the box saysapproximate: true. -
entity_listlost every type-specific property on a live seat with a makepy cache.ModelSpace.Item(andSelectionSet.Item) is declaredIAcadEntity*, so oncewin32com.client.gencachehad run against AutoCAD every listed entity arrived as the gen_pyIAcadEntitywrapper: measured on AutoCAD 2026,item.StartPointon an AcDbLine raisedAttributeError, and a LINE row kept only its bounding box (nostart/end, no polylinepoints, no text). The room reader therefore saw 0 wall segments andarch_plan_from_specrefused its first room label live, while every headless test passed._entity_infonow re-dispatches exactly that wrapper through its runtime class (_com_concrete_entity); a concrete wrapper fromHandleToObjectis left alone. The same narrowing stripsstartfrom every listed LINE, so it would equally explain thepid_graphKeyError: '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, whichasyncio.to_threadcannot 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_snapshotof 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-001yielded the pumpP-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.0drawing 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-2madeCP-2ambiguous indrawing_scale_checkanddrawing_understand, andWiring to CP1a phantom tagCP1. 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.
- A truncated DXF (an interrupted save, a partial copy) made ezdxf raise
-
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_layeris now the one rule for the network, the topology check and thetopo_*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 as6^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 pipe network took every piping or electrical layer, so valve, pump
and annotation layers (sizes, materials, numbers) became pipe runs.
Changed
MAX_DXF_BYTESdefaults 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;0still disables the check.- Three tool groups appear in
system_aboutanddocs/tool-inventory.json:styles(10),page_setup(6),environment(22) — 204 tools in 23 groups;LEAN_TOOL_NAMES55; capability keysdwgprops,dwt_write,live_application,preferences,interactive_prompt,registry_sysvarand thedocuments,named_views,ucsandlayer_states(mode: "xrecord") features in both maps. TOOL_PACKS=all|core,pid,settings(defaultall) advertises only the vertical packs a client uses;coreis always on and unknown names are ignored with a warning. It composes withTOOL_PROFILE=lean(which now carriespid_symbol_insert,pid_line_drawandpid_graph, and the five settings essentials: 55 tools, 52 withTOOL_PACKS=core) andENABLE_3D;system_aboutreportstool_packs. Measured idle cost: 199 tools / 59,315 tokens by default, 190 / 55,593 withTOOL_PACKS=core,settings, 168 / 48,825 withTOOL_PACKS=core, 55 / 16,360 lean, 2 / 356 in discovery mode.PID_LAYERS(drawing_apply_iso_layers("pid")) gainsPROCESS-LINE-TEXT;drawing_plan/drawing_preflightreportLAYER_SET_INTENT_MISMATCHwhen a P&ID intent is planned onto a non-pidlayer set.- The
prompt_pid_diagramprompt template is rewritten onto the tools (pid_from_spec,pid_symbol_insert,pid_line_draw,pid_graph) instead of describing primitives. CritiqueFocusgrows by the sixpid_*values; benchmark matrix v4 (--matrix v3/v2reproduce the earlier sets); correctness suite 29.- README release snapshot: 204 tools · 8 resources · 5 prompts · 3284 collected tests (this machine; it was 166 · 2423 after track A). CI has not run on this tree yet, and the pre-existing 1369-vs-1363 drift between the README and Linux collection predates both tracks and is superseded by the new figure.
Live COM smoke
scripts/smoke_pid_com.py, AutoCAD 2026 (25.1s (LMS Tech)), Windows 11,
2026-09-16, in a new document (Drawing2.dwg) — block_define →
pid_symbol_insert → entity_set_xdata → pid_line_draw → pid_graph →
six focuses → entity_get_xdata, all through the COM engine, exit 0:
{
"autocad": "25.1s (LMS Tech)",
"active_document": "Drawing2.dwg",
"handles": {"P-101": "98", "V-201": "A8", "FCV-101": "B8", "FIC-101": "C5", "OP-1": "CF"},
"graph_stats": {
"nodes_by_kind": {"connector": 1, "equipment": 2, "instrument": 1, "valve": 1},
"edges_by_class": {"electric": 1, "process_major": 3},
"dangling": 0,
"unclassified": 0,
"confidence_min": 1.0,
"entities_scanned": 16
},
"crossings_total": 0,
"critique_issues": 0,
"critique": [],
"xdata_chunks": 2
}
The headless engine reports the identical graph_stats, crossings_total,
critique_issues and xdata_chunks on the same spec. The first attempt
exited 2 on this machine because of a stale win32com gen_py cache for the
AutoCAD type library (a package directory holding only a __pycache__) — an
environment fault, not a backend one; deleting %TEMP%\gen_py\3.11 cleared
it and no backend change was needed.
Live COM smoke — settings (track E)
scripts/smoke_settings_com.py --build-dwt, AutoCAD 2026 (25.1s (LMS Tech)),
Windows 11, 2026-09-22, in a new document (Drawing27.dwg) — styles →
page setup → batch_plot (the PDF's own /MediaBox) → the five .dwt twins
built and iso_a3_mech.dwt reopened → layer state round trip → named view →
UCS → preferences (one reversible set) → SummaryInfo → prompt line, all
through the COM engine, exit 0:
{
"autocad": "25.1s (LMS Tech)",
"system_launch": {
"launched": false,
"attached": true,
"version": "25.1s (LMS Tech)",
"document": "Drawing9.dwg",
"visible": true,
"progid": "AutoCAD.Application",
"backend": "com"
},
"dwt_built": [
{
"name": "iso_a3_mech",
"path": "templates\\iso_a3_mech.dwt",
"bytes": 18240,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
},
{
"name": "iso_a1_arch",
"path": "templates\\iso_a1_arch.dwt",
"bytes": 17281,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
},
{
"name": "iso_a3_pid",
"path": "templates\\iso_a3_pid.dwt",
"bytes": 18528,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
},
{
"name": "ansi_b_mech",
"path": "templates\\ansi_b_mech.dwt",
"bytes": 17250,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
},
{
"name": "ansi_d_arch",
"path": "templates\\ansi_d_arch.dwt",
"bytes": 17278,
"magic": "AC1032",
"is_dwg_container": true,
"format": "dwt"
}
],
"document": "Drawing27.dwg",
"styles": {
"dimstyle": {
"ok": true,
"name": "ISO-25-SMOKE",
"values": {
"DIMTXT": 2.5,
"DIMASZ": 2.5,
"DIMEXE": 1.25,
"DIMEXO": 0.625,
"DIMGAP": 0.625,
"DIMTAD": 1,
"DIMTIH": 0,
"DIMTOH": 0,
"DIMDEC": 2,
"DIMDSEP": ",",
"DIMLUNIT": 2,
"DIMZIN": 8,
"DIMBLK": "",
"DIMTXSTY": "ISOCP",
"DIMLWD": -2,
"DIMLWE": -2,
"DIMSCALE": 1.0
},
"written": [
"DIMASZ",
"DIMBLK",
"DIMDEC",
"DIMDSEP",
"DIMEXE",
"DIMEXO",
"DIMGAP",
"DIMLUNIT",
"DIMLWD",
"DIMLWE",
"DIMSCALE",
"DIMTAD",
"DIMTIH",
"DIMTOH",
"DIMTXSTY",
"DIMTXT",
"DIMZIN"
],
"current": true,
"textstyle_created": true
},
"textstyle": {
"ok": true,
"name": "ISOCP-SMOKE",
"font": "isocp.shx",
"font_resolved": true,
"current": false
},
"mleaderstyle": {
"ok": true,
"name": "SMOKE",
"values": {
"arrow_size": 2.5,
"landing_gap": 1.0,
"text_style": "ISOCP",
"text_height": 2.5
},
"textstyle_created": false
},
"mleaderstyles_on_seat": [
"SMOKE",
"Standard"
]
},
"page_setup": {
"applied": {
"paper": "ISO_A3",
"orientation": "landscape",
"size_mm": [
420.0,
297.0
],
"canonical_media_name": "ISO_A3_(420.00_x_297.00_MM)",
"plot_style": "monochrome.ctb",
"plot_style_known": true,
"scale": "1:1",
"scale_factor": 1.0,
"scale_ratio": [
1.0,
1.0
],
"dxf_standard_scale_type": 16,
"activex_standard_scale": 16,
"plot_area": "layout",
"plot_type": 5,
"device": "DWG To PDF.pc3",
"margins_mm": null,
"center": null,
"paper_units": "mm"
},
"changed": {
"paper": [
"",
"ISO_A3"
],
"canonical_media_name": [
"",
"ISO_A3_(420.00_x_297.00_MM)"
],
"size_mm": [
[
0.0,
0.0
],
[
420.0,
297.0
]
],
"plot_style": [
"",
"monochrome.ctb"
],
"device": [
"Microsoft Print to PDF",
"DWG To PDF.pc3"
],
"margins_mm": [
[
0.0,
0.0,
0.0,
0.0
],
[
17.0,
17.0,
5.0,
5.0
]
],
"paper_units": [
"inches",
"mm"
]
},
"pdf": {
"layout": "Layout1",
"ok": true,
"path": "C:\\Users\\VECTOR\\AppData\\Local\\Temp\\acadmcp_settings_smoke_sapms54v\\Drawing27-Layout1.pdf",
"bytes": 2774,
"mediabox_mm": [
420.158,
297.039
],
"paper": "ISO_A3"
},
"installed_plot_styles": [
"Autodesk-Color.stb",
"Autodesk-MONO.stb",
"DWF Virtual Pens.ctb",
"Fill Patterns.ctb",
"Grayscale.ctb",
"Screening 100%.ctb",
"Screening 25%.ctb",
"Screening 50%.ctb",
"Screening 75%.ctb",
"acad.ctb",
"acad.stb",
"monochrome.ctb",
"monochrome.stb"
]
},
"template": {
"path": "C:\\Users\\VECTOR\\Documents\\GitHub\\Autocad-MCP\\templates\\iso_a3_mech.dwt",
"source": "bundled",
"document": "Drawing28.dwg",
"dimstyle_current": [
"ISO-25"
],
"papers": [
"ISO_A3"
]
},
"documents": [
{
"name": "Drawing9.dwg",
"path": null,
"active": false,
"saved": false,
"entity_count": 0
},
{
"name": "Drawing27.dwg",
"path": null,
"active": true,
"saved": false,
"entity_count": 0
}
],
"layer_state": {
"saved": {
"ok": true,
"name": "SMOKE",
"layer_count": 3,
"replaced": false,
"chunks": 2,
"backend": "com"
},
"restored": {
"ok": true,
"name": "SMOKE",
"applied": {
"layers": 3,
"properties": [
"on",
"frozen",
"locked",
"color",
"linetype",
"lineweight",
"plot",
"current"
],
"current_layer": "0"
},
"missing_layers": [],
"new_layers": [],
"backend": "com"
},
"deleted": {
"ok": true,
"deleted": "SMOKE",
"backend": "com"
}
},
"view": {
"saved": {
"ok": true,
"name": "SMOKE_VIEW",
"center": [
100.0,
50.0
],
"height": 80.0,
"width": 160.9964412811388,
"replaced": false,
"backend": "com"
},
"restored": {
"ok": true,
"name": "SMOKE_VIEW",
"center": [
100.0,
50.0
],
"height": 80.0,
"width": 160.9964412811388,
"applied": "zoom_window",
"backend": "com",
"viewctr": [
100.0,
50.0
],
"viewsize": 80.0
}
},
"ucs": {
"set": {
"ok": true,
"name": "SMOKE_UCS",
"origin": [
10.0,
20.0,
0.0
],
"x_axis": [
1.0,
0.0,
0.0
],
"y_axis": [
0.0,
1.0,
0.0
],
"replaced": false,
"current": true,
"backend": "com"
},
"world": {
"ok": true,
"name": "world",
"current": true,
"backend": "com"
}
},
"preferences": {
"cursor_size": 5,
"read_only": [
"Files.SupportPath",
"Files.TemplateDwgPath",
"Files.PrinterStyleSheetPath",
"Files.PrinterConfigPath"
],
"template_dwg_path": "C:\\Users\\VECTOR\\AppData\\Local\\Autodesk\\AutoCAD 2026\\R25.1\\enu\\Template"
},
"properties": {
"set": {
"ok": true,
"summary_written": [
"author",
"title"
],
"custom_written": [
"ACADMCP_SMOKE"
],
"custom_deleted": [],
"backend": "com"
},
"get": {
"summary": {
"title": "ACADMCP settings smoke",
"subject": "",
"author": "smoke_settings_com.py",
"keywords": "",
"comments": ""
},
"summary_available": true,
"custom": {
"ACADMCP_SMOKE": "1"
},
"backend": "com"
}
},
"prompt_message": {
"ok": true,
"text": "ACADMCP settings smoke: done",
"backend": "com"
},
"failures": []
}
The headless engine reports the identical mediabox_mm (to 1e-9: matplotlib
writes an exact 420 × 297; AutoCAD's PDF driver writes 420.158 × 297.039,
inside spec §9's ±0.5 mm), layer_state.restored and properties.get.custom
on the same steps; summary_available is false headlessly and the five
summary fields are null (capability dwgprops).
What the run refused before the green one, each fixed in the backend and pinned by a fake-ActiveX test:
- Three runs died with
RPC_E_CALL_REJECTED(-2147418111) on the read that follows a document switch —Documents.Countstraight afterDocuments.Open, afterClose(False)insidedocument_close, and in_ensure_document_state— AutoCAD's own message filter refusing the call while it still switches documents. pywin32 has noCoRegisterMessageFilter, so the backend now waits that window out itself (_wait_out_rejected_call, read-only callables only, 0.25 s → 1 s pauses inside a 20 s budget) at those sites; any other error is not retried. layer_liston the reopened template raisedAttributeError: 'IAcadLayer' object has no attribute 'Color'. Did you mean: 'color'?. The type library spellsIAcadEntity/IAcadLayercolorandLineweight; late-bound dispatch resolved.Color/.LineWeightcase-insensitively, but once a makepy cache exists (this machine's was regenerated on 2026-09-17) every object a method returns is a generated wrapper and the lookup is exact. The backend now spells both members as the type library does (works on both wrappers) and the fakes model the measured names.layer_state_restoreraised'IAcadObject' object has no attribute 'Count':Dictionaries.ItemandIAcadDictionary.Itemare declaredIAcadObject*(the narrowing_com_unnarrowalready handled for MLEADER styles); the save before it went through the typedDictionaries.Add, so only the reopen paths failed. Both now un-narrow.- A state saved with layer
0current came back with two warnings and0unapplied: theFreezeput on layer0raises 'Invalid layer' whatever the value, evenFalsewhile thawed (measured on a scratch document;color/Linetype/Lineweight/Plottable/LayerOn/Lock/ActiveLayerall fine on it).Freezeis now written only when it would change the layer. - Two of the smoke's own expectations predated the merged tree and were
corrected to it:
mleaderstyle_createis implemented on COM through theACAD_MLEADERSTYLEdictionary (spec §4: nomleaderstylekey exists), so the smoke now asserts the created style is listed witharrow_size2.5; andplot_style_listmarks catalogue rows withinstalled: true(thesource: "installed"rows are files the catalogue does not know), so themonochrome.ctbcheck reads that flag.
--interactive (user_pick_point) was not run in this session: the seat is
shared with other agents and nobody was confirmed at the keyboard, and an
unanswered Utility.GetPoint holds AutoCAD's command line — every COM client
gets RPC_E_CALL_REJECTED — until someone presses Esc, past the 60 s timeout.
The pick path was executed live earlier in the track (Task 23's measurement:
GetPoint answers in WCS while GetEntity answers in the current UCS). Each
plot also dropped AutoCAD's plot.log into the repo root; it is ignored now.
Live COM smoke — mechanical parts and the sheet (tracks B + G)
scripts/smoke_mech_com.py, AutoCAD 2026 (25.1s (LMS Tech)), Windows 11,
2026-09-23, in new documents only (an xref source saved and closed first, then
the sheet) — apply_layer_set("mech") → apply_titleblock (A3, zones, marks)
→ draw_part → add_view(section) → dimension_part → insert_std_part →
weld_symbol_prims → extract_records → draw_bom_table → add_balloon →
xref_attach / xref_manage("list") / xref_manage("detach") →
run_critique(focus=None) → drawing_export_dwg("R2018"), all through the COM
engine, exit 0, every scratch document closed afterwards:
{
"autocad": "25.1s (LMS Tech)",
"part_id": "103",
"views": 2,
"section_omitted": [],
"dimensions": 12,
"bolt_handle": "2F7",
"xdata_chunks": 1,
"parts_list_rows": 1,
"parts_list_representation": "native",
"balloon": "31A",
"xref_attached": "XREF_SOURCE",
"xref_listed": 1,
"xref_detached": true,
"xrefs_after_detach": 0,
"dwg": {
"path": "%TEMP%\\mech_smoke_*\\mech_smoke.dwg",
"exists": true,
"bytes": 31687,
"magic": "AC1032"
},
"critique_issues": 0,
"critique": []
}
The first runs found the four live defects listed under Fixed (the MECH
layer, BLOCKREFERENCE, the dimension text_position, the refused LINE read)
and one harness fault: the first call after Documents.Add can be refused with
RPC_E_CALL_REJECTED while the new document initialises, and a refused
cleanup then left the scratch document open. The smoke retries refused calls
for up to 90 s (a refused call was not executed) and tracks its documents by
the document list rather than by an assumed name, because SaveAs renames the
document it writes.
Live COM smoke — architecture (track F)
scripts/smoke_arch_com.py, AutoCAD 2026, Windows 11, in a new document only —
draw_plan_from_spec(EXAMPLE_SPEC) (walls, openings, stair, two room labels,
chains, three schedules in one transaction) → read_plan → rooms_detect →
run_critique(focus=None), all through the COM engine, exit 0, the scratch
document closed afterwards:
{
"autocad": "25.1s (LMS Tech)",
"walls": [
"EXT",
"INT"
],
"openings": [
"D1",
"D2",
"W1",
"W2"
],
"omitted": [],
"stairs": 1,
"schedules": [
{
"kind": "doors",
"representation": "native",
"rows": 2
},
{
"kind": "windows",
"representation": "native",
"rows": 2
},
{
"kind": "rooms",
"representation": "native",
"rows": 3
}
],
"rooms": {
"01": {
"label_mm2": 27743750.0,
"detected_mm2": 27743750.0,
"hand_mm2": 27743750.0,
"agrees": true
},
"02": {
"label_mm2": 21993750.0,
"detected_mm2": 21993750.0,
"hand_mm2": 21993750.0,
"agrees": true
}
},
"faces_detected": 2,
"confidence_min": 1.0,
"critique_issues": 0,
"critique": []
}
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:
{
"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 adversarial pass over the rewritten README. 1.5.0 is yanked: its diameter and radius callouts put wrong numbers on drawings at default settings, and that is not something to leave installable.
Fixed
- The HTTP bind guard failed open on the launch path it was written for.
_guarded_run_asyncread the bind address from thehostkwarg and treated an absent one as loopback. It is not:fastmcp runforwards that kwarg only when--hostwas passed, and otherwise fastmcp resolves the host fromfastmcp.settings.host, which is environment-backed. SoFASTMCP_HOST=0.0.0.0 fastmcp run server.py:mcp --transport httpbound every interface — 149 tools including file open/save and AutoCAD command execution, with no token — while the guard inspected the literal string"127.0.0.1"and waved it through. v1.4.0's changelog claimed the guard "now fires on every launch path"; it fired, and then looked at the wrong host. It now resolves the effective host, and says so in a warning if it cannot. [com]and[full]could not be installed off Windows. Both extras listedpywin32>=306with no environment marker, and pywin32 publishes Windows wheels and no sdist — sopip install autocad-mcp-pro[full]died at resolution on Linux and macOS, and[full]is the extra this project's own README recommended to contributors. CI never caught it because every Linux lane installs[pdf]or syncs fromuv.lock, whose resolution markers hide the gap. Both entries now carry; sys_platform == "win32", and a release-consistency test fails if a Windows-only distribution loses its marker again.drawing_settings({"dimscale": ...})reported success and changed nothing headlessly. It writes the$DIMSCALEheader variable, which is what AutoCAD consults when it draws a dimension — so the call worked on the live backend. ezdxf renders from the dimstyle table entry and never reads the header, so the same call produced two different drawings on the two engines while both saidok. The header value is now folded into the per-dimension override on all five dimension tools, includingdimension_alignedanddimension_angular, which do not share the tolerance-override path and were missed by the obvious fix. DIMSCALE 1.0 writes no override.
Changed
- README: the "What you get" table qualified every engine-conditional family (boundary tracing, WIPEOUT/REVCLOUD/MTEXT masks, CHSPACE, typed hatch edge paths, headless undo) instead of listing them as if both engines had them, and the checkout install line no longer recommends an extra that cannot resolve on the reader's platform.
[1.5.0] — 2026-08-06
Added
-
Selection filters — 3 tools.
selection_window(AutoCAD's ssget window/crossing),selection_polygonandselection_filter(QSELECT). Window returns only entities wholly inside; crossing also returns the ones straddling the edge — the modes are reported back andwindowis the default, because the over-selecting mode must never be the implicit one. A zero-area box is refused rather than answered with an empty list, which would read as "nothing there".selection_filtertakes named parameters rather than a query string deliberately: ezdxf's query language answers an unknown attribute with an empty result, soLINE[nosuchattr=='x']is indistinguishable from "no matches". Here a typo is aTypeErrorat the boundary, andfiltered_byreports which filters actually ran.min_areaexcludes shapes that have no area at all rather than treating them as zero, and a negativemin_areais refused — no area is negative. -
Boundary tracing — 2 tools, and five deliberately not built.
boundary_traceis AutoCAD's BOUNDARY/BPOLY: a seed point in, the nearest enclosing closed polyline out. It never callsedgeminer.find_all_loops— measured at 63.9 s for 40 edges and a TimeoutError at 60, because the docstring's O(n!) is real — and walks one loop per seed-adjacent edge instead, which is 0.0057 s at the same size. Straight edges are split where they cross, so a construction line drawn across a shape actually divides it; without that the line shares no endpoint with anything and is invisible to the edge graph. Curved edges are not split, and the capability says so. Two things the obvious implementation gets silently wrong, both now tested:edgesmith.lwpolyline_from_chainemits n+1 points with the last duplicating the first andclosedleft False — a polyline that draws like a square and behaves like an open path to hatch association, area queries andentity_offset; andedgesmith.loop_areaworks off edge endpoints, so two semicircular arcs closing into a circle measure 0.0 against a true 314.159. The area now comes from the flattened curve and each curved edge keeps its bulge. Not built: every REGION and 2D-boolean tool the plan listed. ezdxf cannot author a REGION —add_region()returns one with zero ACIS bytes — and the substitute,greiner_hormann, takes flat vertex lists. Measured on a square with one edge bulged into a semicircle, routing it through that boolean loses 28.2% of the area and 12.5% of the perimeter. That is the same magnitude as theanalysis_measure_areabug this release fixed, and shipping it would have re-introduced the class. -
Hatch depth — 3 tools.
hatch_set_gradient,hatch_editandhatch_add_boundary.hatch_editleaves omitted parameters alone — a partial edit that resets the rest is data loss — andchangedreports the attributes that actually moved, so re-setting a value to what it already was comes back empty rather than as a false positive.hatch_add_boundarytakes typed edges (line/arc/ellipse) because a boundary API that only accepts vertex lists silently straightens every curve it is given; every edge is validated before any is written, so a malformed list refuses instead of leaving a half-built path. On COM it refuses withcapability: "hatch_edge_paths": ActiveX appends boundary loops as existing objects, not as typed edges, so the fidelity this tool exists for cannot be expressed there. -
analysis_list_properties— AutoCAD's LIST. The raw DXF attribute set for one handle, whichentity_getdeliberately does not carry. It cannot be handed over as-is for two reasons, both fixed here:dxfattribs()holdsVec3values and is not JSON-serialisable, and its coordinates are in the entity's own frame, so for anything with an extrusion they would contradict every other coordinate this server reports. -
Annotation objects — 4 tools.
entity_create_wipeout(mask what is behind a closed polygon),entity_create_revcloud,text_set_background(MTEXT background mask) andtext_find_replace. Each carries a refusal that was measured rather than assumed. A wipeout of fewer than three points encloses no area, so it is refused instead of returning a handle to a mask that masks nothing. A revision cloud is its arcs —ezdxf.revcloud's own helper stamps theRevcloudPropsxdata and takes the bulge sign from the winding direction, so the result satisfiesis_revcloud()— and asegment_lengthlonger than the shortest edge produces no arcs at all, which would be a plain rectangle reported as a cloud; that case is refused.text_set_backgroundrefuses TEXT (only MTEXT has a background-fill attribute, so setting one on TEXT would report success and change nothing) and refuses a scale below 1.0 (a box smaller than its text is a stripe through the text).text_find_replacesearches TEXT, MTEXT, ATTRIB and ATTDEF, including inside block definitions — leaving the ATTDEF behind would fix the drawing and reintroduce the old text on the next insert. It returnssearched_types, because "no matches" and "that type was never searched" are different answers and a caller cannot otherwise tell them apart. DIMENSION text is deliberately out of scope: its text field holds the<>override placeholder rather than the measurement, so editing it would either do nothing or detach the dimension from what it measures — the response says so. On COM,entity_create_revcloudrefuses withcapability: "revcloud": ActiveX has no revision-cloud member and driving the command blind against a live drawing is unverified. -
The rest of the paper-space lifecycle — 8 tools. v1.4 could make a sheet and never unmake it. Added
layout_delete,layout_rename,layout_copy,viewport_list,viewport_set_scale,viewport_lock,viewport_deleteandentity_change_space(CHSPACE). Verified headless (ezdxf); the COM paths for the first seven are implemented against the ActiveX reference but never executed against a live AutoCAD, and each is marked so in the source. Most of the work went into refusals, because ezdxf's own guards stop short of where a tool boundary has to:layouts.delete("")deletes the first sheet rather than refusing,layouts.get("")resolves to whichever is active, andlayouts.rename()skips the name validationnew()performs, soBad/Nameand even""land in the table and the file still audits clean. Deleting or renaming the current tab used to leave three different answers to "where does my next line go?" —layout_listsaid one thing,$TILEMODEanother, and_msp()quietly wrote to model space; all three now agree. A handle from a deleted sheet refuses instead of dying withAttributeError(entitydb.getis documented not to filter destroyed entities, and_get_entitybelieved it).layout_copyis a construction — ezdxf has no layout-copy API andxref.load_paperspaceis cross-document only — and every way it can go wrong is invisible to the Auditor, which reports zero errors on a duplicated tab order, two main viewports, and a hatch whose boundary still points into the layout it was copied from and dangles the moment that layout is deleted. All three are prevented structurally;skippedandassociativity_droppedreport what could not be carried over rather than letting a bareok: trueimply a complete copy.viewport_listreports the layout's own main viewport withis_main: truerather than hiding it — it is what remains after every drafting viewport is deleted — and refuses to report a scale on R12, whereview_heightandflagsare dropped on export andget_scale()then returns a fabricated number (150.0 measured for a viewport storing no scale at all). The two writers refuse on R12 outright rather than reporting a success the file cannot keep, andviewport_lockread-modify-writes the flags bitfield, which also carries the UCS-icon and grid bits a bare assignment would silently drop. -
entity_change_space(CHSPACE), headless only. AutoCAD moves objects through a viewport, rescaling them so they look identical on screen. ezdxf'smove_to_layoutapplies no geometric change whatsoever, so the obvious implementation reports success, turns a 100 mm feature into 100 mm of paper inside a 1:2 viewport, and writes a perfectly clean file. The transform is therefore the tool, and it usesviewport.get_transformation_matrix()rather than a hand-rolled one: the hand-rolled version ignoresview_target_pointand misplaced geometry by 25 mm in testing whileis_top_viewwas still true and the twist angle still zero — past every guard one would naturally write. A regression test pins the result against what ezdxf's own rendering pipeline projects. It refuses rather than guessing: non-plan and twisted viewports (ezdxf returns a meaningless matrix for the first and rotates about the paper origin for the second), dimensions unlessfreeze_dimensions=truebakes the measurement in first (transformhalvesget_measurement()while the baked block text keeps the old value, so one of the two is always lying), ACIS bodies (transformis accepted and moves nothing), tables and proxies (transformraises, andhasattr(e, "transform")isTruefor all of them), viewports themselves, and entities already in the target space. Geometry that lands outside the viewport or off the sheet is moved and flagged (inside_viewport,on_sheet), not refused — AutoCAD allows it too. On the COM backend this refuses withcapability: "chspace": ActiveX has no change-space member, so the alternatives areCopyObjects+ delete or driving the command blind, and neither could be verified against a live seat. -
CAD_PROGID— the COM ProgID is configurable."AutoCAD.Application"was hardcoded at both connection sites, so AutoCAD-compatible applications (BricsCAD, ZWCAD, GstarCAD) were unreachable even though they expose the same ActiveX object model. The setting is honoured on both paths deliberately:Dispatchis not a passive probe — it COM-launches the application and setsVisible = True— so honouring the setting onGetActiveObjectand then falling back would start the very AutoCAD the operator said they were not using. An unrecognised ProgID fails loudly for the same reason, and the error names both the ProgID it tried and the variable to change.system_statusnow reportscad_progid, without which a non-AutoCAD operator reads anautocad_versionkey naming a product they do not own. Caveat, stated everywhere it appears:CAD_PROGIDonly changes which COM application the backend attaches to — every tool beyond the connection itself is developed and tested against AutoCAD alone, and clone compatibility is unverified, so treat a non-default ProgID as experimental. The candidate ProgIDs in.env.exampleare commented out and marked UNVERIFIED: none has been tested against a running seat. -
analysis_measure_entity— measure the drawing, not your memory of it. Neither backend could answer "what is the area of the polyline I just drew":analysis_measure_areashoelaced points the caller typed in, and the live COM backend did the same despite having the document, aHandleToObjectlookup it already used fourteen times, and an ActiveXAreaproperty. The only available workflow was also silently wrong —entity_getread LWPOLYLINE vertices through the default format and kept x and y, dropping the bulge that makes an edge an arc. Measured on a 100×100 square with one edge bulged into a semicircle: 10000 reported against a true 13926.9908, 28.2% low, perimeter 12.5% low, nothing in the response saying so. Andprops["length"] = ent.length()was dead code —LWPolylinehas nolength(), so the key never appeared at all. Bulge geometry is always circular, so the newengineering/measure.pyis exact rather than approximate: shoelace plus each arc's signed circular segment, and arc length for perimeter.entity_getnow reportsareaandlengthfrom the same maths. The payload states its own accuracy —exact,flatten_tolerance,assumed_closed(an open boundary is closed the way AutoCAD's AREA does), andself_intersecting, because the shoelace cancels crossed lobes and a bowtie measures 0.0. REGION/3DSOLID refuse withcapability: "measure_area_acis"naming the live backend; LINE/TEXT/INSERT raise a plain error, since no engine can give them an area and a capability tag would falsely suggest switching helps.AREA/MEASUREGEOMnow route here rather than at the points-based tool — a drafter typing AREA picks objects, they do not retype coordinates. -
Handle grounding:
view_screenshot(overlay_handles=true). A screenshot showed the model what the drawing looks like and nothing it could act on — every modify tool takes a handle, and nothing connected "the circle at the top-left" to a hex string. Each entity is now labelled with its handle at its own bounding-box centre (taken from the renderer's extents, so the label lands where the entity is drawn even in a rotated frame). Off by default, because labels are ink on the drawing; past 40 entities the render is capped and the image itself says "handles shown for N of M" rather than letting a labelled subset read as the whole sheet. The live backend captures the AutoCAD window rather than rendering, so there is nothing to label there — it refuses withcapability: "handle_overlay"instead of quietly returning an unlabelled image, since producing the overlay would mean creating and deleting entities in the user's open drawing. -
block_create_from_entitiesworks on the live backend. It refused outright and pointed atsystem_run_commandwith_BLOCK, pushing callers through the free-text escape hatch for something ActiveX does directly (Blocks.Add+Document.CopyObjects). The tool's own docstring said the opposite — "works by using AutoCAD's BLOCK command (COM backend only)" — so the one engine that could not do it was advertised as the one that could. Both engines now also reportskippedhandles instead of swallowing them, and a call where nothing resolves fails rather than leaving an empty definition behind. COM path unit-tested against a fake ActiveX object, not hardware-verified. -
Undo and redo on the headless backend (
EZDXF_UNDO_DEPTH, default 0). The plan called this "add redo". The larger half was that undo never worked either:_undo_stackwas declared, read and cleaned up, but nothing ever appended to it, sodrawing_undocould only answer "Nothing to undo" — while the tool summary said "Step back one operation" and the alias corpus routedUNDOand "take that back" straight at it. The old test assertedisinstance(result, dict), which a permanent failure satisfies. It is opt-in because it is not free. ezdxf keeps no journal, so a history step is a whole DXF snapshot: measured 3.1 ms / 19 KB at 10 entities but 130 ms / 658 KB at 5000, and switching it on costs 37× on entity creation (0.18 → 6.65 ms per call) even on a small drawing. Off, it costs nothing; on, you get real multi-step undo/redo. Both tools refuse withcapability: "undo_history"naming the setting rather than pretending. The live COM backend uses AutoCAD's own undo and ignores it. Drawing after an undo discards the redo branch, as in AutoCAD — otherwise redo would restore a state that never existed, with geometry you had removed reappearing beside geometry you drew afterwards. -
BREAKING (behavior): geometry now goes into the layout you selected.
layout_set_current("A3-Sheet")answered{"ok": true, "current": "A3-Sheet"}and then every entity created afterwards landed in model space anyway, on both engines —_msp()returned model space unconditionally, so the tool reported a state change that changed nothing. Proven: a line drawn straight after selecting a layout was in modelspace and not in the layout. That was also why a title block could not go on a sheet, sotitleblock_apply_iso_a3gains alayoutparameter — where a title block belongs, since the border frames the printed sheet rather than the model — and restores your current space afterwards rather than leaving you on the sheet. Existing model-space workflows are unaffected: a fresh drawing starts in model space, and a refused layout switch leaves the current space where it was. -
ISO 286 transition and interference holes (K, M, N, P).
fit="P7"used to raise by name. They are now derived by the standard's delta rule,ES = -ei + ΔwithΔ = IT(n) − IT(n−1)— a rule over the shaft and IT tables already in the module, not new table data, which is why it can be pinned against published values (K7/M7/N7/P7 and K6/N6/P6 cross-checked by hand) instead of merely trusted. Unlocks the hole-basis pairs K7/h6, M7/h6, N7/h6, P7/h6. -
ISO 286 interference shafts (r, s, t, u).
fit="s6"andfit="u6"— the standard press and drive fits — used to raise. Unlike K/M/N/P these are new table data, sub-stepped more finely than the main size steps (ISO 286 splits 18–30 into 18–24/24–30, 30–50 into 30–40/40–50, and every step above 50 mm in two or three; flattening that is a silent error of tens of microns at exactly the sizes press fits are used at). Because it is data rather than a rule, its provenance is tested: every value is cross-checked against ISO 286-1's own derivation formulae, an independent source.rreproduces as the geometric mean ofpandson 24/24 steps — withppinned in this module beforerexisted — ands/t/utrack their IT7-based formulae within 1 µm on every step from 18 mm up. The five places where the standard's rounding puts a published value 2–3 µm off its own formula are listed by name in the tests, so a reader holding ISO 286 checks five numbers rather than ninety-five. Shafttis undefined below 24 mm in the standard and refuses there rather than being extrapolated. Unlocks H7/r6, H7/s6, H7/t6 and H7/u6. -
Headless performance lane (
benchmarks/perf_suite.py+render_perf_chart.py): fixed wall-time workloads (2k individual creates, 10k build/export/reopen roundtrip, 10k region query, full premium quality pass) measured through the same backend methods the MCP tools call; machine fingerprint recorded, self-measurement only. -
Task-matrix chart (
benchmarks/render_matrix_chart.py): tasks × servers status heatmap rendered from the published live-run reports, so coverage gaps are visible per capability instead of one folded score. -
Tool discovery (
DISCOVERY_MODE=search, defaultoff): replaces the advertised catalog withsearch_tools+call_tool, so a client sees two tools instead of 127. Ranking layers an AutoCAD command / synonym corpus (discovery/aliases.py, 132 tools · 148 command tokens · 583 synonyms) over fastmcp's BM25 index, and hits are returned as one compact line per tool rather than full JSON schemas. Measured on a 66-query golden set with a 15-query holdout: top-1 62/66 vs 23/66 for stock BM25, top-3 64/66 vs 33/66 (holdout top-3 14/15 vs 9/15); stock returns nothing at all for 21 of the 66, and this transform for none of them — that gap is the corpus, not the ranking, and no BM25 parameter closes it. (Both sides moved slightly during the release as tool docstrings were rewritten: better prose feeds both indexes, which is why the headline is the top-1 gap rather than a ratio between two scores over a shared corpus.) Ariskceiling keeps destructive tools out of read-only answers and says when it hid one. Off by default — flipping it changes what every connected client sees onlist_tools, which needs its own migration note. -
@cad_tool— a discovery-metadata channel next to the tool contract. A decorator written above anmcp.toolregistration attaches acadkey to that tool's MCPmeta: a one-linesummaryfor a search hit's preview, acostfrom a closed set (read,safe,mutate,destructive,escape) for clients that want to gate or colour-code the surface before calling, and the tool's AutoCAD command names and synonyms — imported fromdiscovery/aliases.py, never restated, so the corpus stays the one edit site.tags=andannotations=deliberately stay on themcp.toolcall, so the channel cannot perturb_tool_groups()or the registration-counting release gate. Misuse fails at import rather than silently no-opping: an unknown cost raises, and a decorator written below the registration (or on a bare function) raises with the ordering rule in the message. Applied to the threetransaction_*tools so far; every other tool still falls back to the first line of its docstring in search results, which is why the rollout can be section-by-section instead of a 131-tool patch. -
EZDXF_CALL_TIMEOUT(default120s,0disables): per-call timeout for the headless backend, mirroringCOM_CALL_TIMEOUT. Every ezdxf method runs under one document lock, so a single hung call previously deadlocked every later tool call. -
uv.lock— the dependency graph CI actually installs. 95 packages, resolved universally overrequires-python >=3.11, so one file serves the Linux 3.11/3.12 matrix and the Windows leg (platform-only packages like pywin32 carry markers). Every lint and test lane now installs withuv sync --locked, which fails rather than re-resolving when the lock andpyproject.tomldisagree — a fresh CI run can no longer pick up an upstream release nobody chose, and the locked set is the set the suite is green on. Thepackagejob stays unlocked on purpose: it is the only job that proves the published dependency ranges still resolve for a plainpip install autocad-mcp-pro. Theuv.lockline in.gitignore(added when the committed lockfile was stale) is gone with it — a lockfile git ignores is not a lockfile. -
Scheduled floating-dependency job (
.github/workflows/deps-floating.yml): weekly and on demand, it ignores the lock, resolves the declared ranges fresh on 3.11/3.12 and re-runs the suite. Locked lanes cannot be broken by an upstream release, which also means they cannot notice one; this is where a range ceiling that needs moving shows up. It never gates a pull request. -
Benchmark matrix v3 — five tasks that can fail. The v2 matrix scored this server 10/10, which was not evidence of anything: every task in it exercised something the server was built around, so full marks were the only available outcome.
benchmarks/tasks_v3.pykeeps the v2 ten byte-for-byte (their ids and weights key the published v1.4 reports) and addstool_discovery,token_budget,hatch_islands,selection_filterandmeasure_from_handle. Each verifies against a value derived independently of the code under test — a closed-form area, entities placed on purpose, a token ceiling fixed before the measurement. Three of the five failed while being written, and the defects they found are in Fixed below. Two are renamed from the release plan, and the renames are the point rather than bookkeeping.tool_discovery_bilinguallost its "bilingual": the Turkish query normalizer was written and then removed, because this repository is English throughout and the Turkish was inferred from the author's chat language, not required by the product — a task named "bilingual" would benchmark a capability that does not exist.measurement_masspropsbecamemeasure_from_handle: no centroid or moment-of-inertia tool ships, since F2 was cut toentity_measure. The competitor reports are not re-scored. Those servers were pinned and run against v2 at v1.4 and have never been asked these five questions; the matrix chart renders the five rows blank with anot runlegend entry rather than zero, because an invented zero is indistinguishable from a measured one.run_competitorsgrows--matrix v2|v3(v2 stays addressable so a v1.4 report can be reproduced rather than only described), records which matrix ran in the report, and grows--publish, which writes the artifact-path-free file thatresults/published/holds — that sanitisation used to be a manual edit. -
A capability refusal in the benchmark runner is now recorded
unsupportedinstead offail. The result enum has carriedunsupportedsince v2 for exactly this, but a refusal is still an exception and was being caught by the generic handler — "your engine cannot reach this" and "your server got this wrong" are different findings about a competitor. -
Five checks added to
correctness_suite.py(26, was 21), and the two kinds read differently in the A/B table. Three measurement checks are new capability — v1.4.0 misses them by not having the method (miss → pass) — and are there because all three broke during this release. Two dimension checks are repaired defects: v1.4.0 has the methods and gets them wrong (fail → pass). Result against v1.4.0: 21/26 → 26/26, five fixed, zero regressed.
Changed
-
The backend contract is 19 modules instead of one 1854-line file, and optional methods are declarative.
backends/base.pyheld a single ABC with 112 abstract methods; adding one made both backends fail at instantiation until both were written, and two test modules constructComBackendat import time, so a half-finished contract edit took the entire suite red. The interface now lives inbackends/contracts/, one module per domain, split along the section markers that were already in the file — the seams were not re-decided.AutoCADBackendis the composition of all 19 and stays the import surface, sofrom backends.base import AutoCADBackendand everyisinstancecheck are unchanged, and the abstract-method count is identical before and after. The split surfaced something the file's name hid: roughly 740 of those lines are shared concrete implementation (premium meta-tools, GD&T, the settings facade), not interface. Those slices are mixins and are labelled as such rather than presented as pure contracts. Newbackends/capability.pycarries@capability(key, reason=...), which marks a contract method optional and supplies a default. The default raisesUnsupportedCapabilityErrorrather than returning a quiet{"ok": False}— a method nobody implemented has to be indistinguishable from a deliberate refusal, not from a soft failure, which is the distinction this release spent a milestone restoring. A key used by the decorator must be declared in both capability maps ortests/test_capability_contract.pyfails; without that gate the decorator would just be a way to ship refusalssystem_capabilitiesnever mentions, which is worse than the build break it replaces. Its first user is COM'sentity_change_space, which now inherits the refusal instead of hand-rolling it. The refusal vocabulary moved into this module as well (the contracts need it andbaseimports the contracts);backends.basere-exports it, so there is still exactly one exception class object in play. -
BREAKING (env): the
coretool profile was removed.TOOL_PROFILEis nowleanorfull; the discovery layer above solves the crowded-surface problemcoreexisted for.TOOL_PROFILE=corestill boots — it falls back tofullwith a warning naming the removal, andsystem_aboutreports the requested profile alongside the applied one. -
BREAKING (install):
ezdxf>=1.4is now the floor (was>=1.3). The 1.4 line is the only one this release is developed and tested against; 1.3 is no longer exercised by CI and is unsupported. Upgrade withpip install -U "ezdxf>=1.4". -
Runtime pins narrowed. A published library cannot pin exactly and stay co-installable, so what a user's
pip installgets is still a range — and a range is the only guard that install has. They are therefore deliberately narrow. (uv.lock, above, is the exact guard on our side; the two are not substitutes for each other.)fastmcp>=3.0→fastmcp>=3.4.5,<3.5.fastmcpis now a thin meta-package (Requires-Dist: fastmcp-slim[client,server]) and 3.x moves fast enough that a new minor can reshape the API the server is built on. Tested against 3.4.5; the ceiling moves only after the suite is re-run.ezdxf>=1.3→ezdxf>=1.4,<2(see the BREAKING note above).pydantic>=2.0→pydantic>=2.0,<3, so a future pydantic 3 cannot be pulled into an install of this release.
-
Rendering requires an extra — documented, not changed.
ezdxf.addons.drawing.__init__imports.frontend, which does an unconditional top-levelimport PIL.Image. Pillow is therefore a hard requirement of every ezdxf render path (PNG screenshot and PDF export alike), not just of the COM window-capture path it is listed under. A barepip install autocad-mcp-proresolves 77 packages with neither Pillow nor matplotlib, so it cannot render at all;[pdf](or[full]) is required, and satisfies Pillow transitively via matplotlib. Note that[com]alone installs Pillow but not matplotlib, so the ezdxf backend still cannot render under it. Pillow deliberately stays out of the core dependencies for now. -
CI lint pinned to
ruff==0.16.1(was an unpinnedpip install ruff/ruff>=0.8). The version is now written in exactly one place —[dependency-groups] dev— and frozen there byuv.lock; the workflows install ruff from the lock instead of restating the pin, so the three copies that had to be bumped together no longer exist. -
Dev toolchain moved to a PEP 735 dependency group.
[tool.uv] dev-dependencies, which uv now warns is deprecated, became[dependency-groups] dev— same four tools, byte-identical resolution. The standard spelling is also understood by pip >=25.1, sopip install --group devgives contributors who do not use uv the same list without a second copy of it. -
CI and the release gate install from the lock.
lint(dev group only — ruff without building the project),test-linux,test-windowsand therelease.ymltest job now runuv sync --locked+uv run --no-sync, with uv itself pinned in the workflow env for the same reason ruff is.package,dockerandregistry-validateare unchanged.benchmark.ymlis locked too: that lane publishes evidence, and an ezdxf release moving our own numbers between runs would make the diff unattributable. -
README rebuilt: PyPI-first install, a four-lane benchmark section with Python-generated visuals (scores, task matrix, performance, source review), v1.4 feature table, updated client/config/roadmap sections.
-
render_live_chartnow skips non-task-matrix reports so multiple lanes can sharebenchmarks/results/published/.
Fixed
-
The COM paths were run against a live AutoCAD 2026, and four of them were broken. Every COM implementation added in this release was written against the ActiveX reference and shipped labelled unverified. Executing them against a real seat — a fresh instance, a throwaway drawing, nothing saved — found four defects that no amount of reading would have caught, one of them pre-existing:
AcadPViewporthas noViewCentermember.viewport_listdied on it outright. Worse,viewport_createhas carried the same line since v1.4 wrapped in a bareexcept: pass, so it raised on every single call and said nothing — every COM viewport has been looking at the model origin instead of theview_center_x/ythe caller asked for. The member isTarget, and the failure is no longer swallowed.ModelSpace.AddWipeoutdoes not exist. ActiveX has no wipeout constructor at all; WIPEOUT is a command. Now a typed refusal (capability: "wipeout") instead of anAttributeError.AcDbMText.BackgroundFillColordoes not exist — it raises on read as well as on write, with an int and with anAcCmColorobject alike.BackgroundFill(the on/off flag) does work, sotext_set_backgroundenables and disables the mask on COM and refuses a colour request withcapability: "mtext_background_color", rather than switching the fill on in the wrong colour and reporting success.analysis_list_propertiescalled a helper that does not exist on the class. After the fixes, all 25 verification calls behave correctly: 18 succeed, 1 refuses per-argument (deleting model space), and 6 refuse with the capability keys they declare. The remaining COM refusals (chspace,revcloud,boundary_trace,hatch_edge_paths) were confirmed to hold rather than leak a result. The source comments now say VERIFIED with the date instead of UNVERIFIED.
-
The headless renderer does project model content through viewports. v1.4 said it did not.
viewport_renderwas declared unsupported on ezdxf, everydrawing_export_pdf(layout=...)response carried the note "projecting model content through viewports is COM-only", and the README table agreed — so a caller with no AutoCAD was told to go get one for something that already worked. Measured on ezdxf 1.4.4: a sheet holding nothing but a viewport renders the model geometry that viewport looks at, two viewports at 1:1 and 1:2 render the same circle at two sizes in the right places, and geometry outside a viewport's window is correctly left out. Understating a capability is the same class of untruth as overstating one, and it is the more expensive one for the user. What is genuinely missing is the viewport border, which the headless renderer does not draw — the capability now says exactly that (reason: viewport_borders_not_drawn), and the old note's claim that "viewport frames are rendered" was wrong in the other direction. Two tests that pinned the false claim were corrected rather than deleted, and the replacement proves the behaviour by subtraction: the sheet renders less ink once the model geometry is gone. -
Layout names are matched case-insensitively, the way the engine underneath already matched them. ezdxf resolves layout names case-insensitively in
get,new,rename,deleteandset_active_layout— butnames()returns the stored spelling, so every guard written asname in doc.layouts.names()disagreed with the code it was guarding, in both directions.layout_create("SHEET")next to an existingSheetwalked past its own duplicate check and died on a rawDXFValueErrorfrom inside ezdxf;layout_set_current("SHEET")reported "Layout not found" for a tab that was right there. Both shipped in v1.4. One resolver now backs create/set_current/delete/rename/copy andviewport_liston both engines, and the current space is stored in the canonical spelling. Re-spelling a layout's own name (Sheet→SHEET) is a rename onto itself rather than a collision; ezdxf'srenamerefuses that outright, so it goes through a staging name. -
entity_change_spacerefuses an entity that lives on another sheet instead of half-applying.move_to_layoutraisesDXFValueErrorfrom deep inside ezdxf when the entity is not in the source layout — and it raises after the transform has already been applied, so the failed call left the line rescaled and translated by a viewport it never moved through, still sitting on the wrong sheet. Membership is now checked before anything is written, and the refusal names the space the entity is actually in. One bad handle in a batch no longer aborts the good ones. -
BREAKING (behavior): coordinates crossing the boundary are now WCS. They were not. DXF stores CIRCLE, ARC, LWPOLYLINE, TEXT, INSERT and a dimension's text position in a per-entity frame derived from the entity's
extrusionvector — and the string "extrusion" appeared nowhere in this repository, so every such read returned a raw attribute rather than a world coordinate. Mirroring flips extrusion to(0,0,-1)and leaves the stored x unnegated, so after the server's ownentity_mirroracross the Y axis a circle drawn at (30, 20) reportedcenter [30.0, 20.0]while the same response'sbounding_boxsaidmin [-35, 15] / max [-25, 25]: the payload contradicted itself by 60 mm, silently, with no error anywhere.point_from_snapreturned points on the wrong side of the axis,entity_select_smartfound the entity where it wasn't, andentity_offsetplaced the new circle 60 mm away and grew it when asked to shrink. Reads are now normalised at the single_entity_info_dxffunnel, so all 38 call sites and every consumer ofEntityInfo.propertiesare fixed with them. The write paths (entity_edit_geometry,entity_offset) got the matching inverse — mandatory, not cosmetic: read and write were equally wrong, which made a read-then-write round trip an accidental no-op, and fixing reads alone would have turned that no-op into a real 60 mm move.- Not touched, deliberately: LINE, MTEXT, ELLIPSE, SPLINE and POINT carry
an
extrusionattribute too and were always correct (ezdxf makes theirocs()a pass-through);entity_rotatewas never affected — it preserves extrusion(0,0,1). The trigger wasentity_mirrorand foreign DXF files. A dimension'sdefpointis DXF group code 10 and WCS already; onlytext_midpoint(code 11) is in the entity frame. - Tilted planes. An entity whose plane is not parallel to WCS XY cannot be
described by an xy pair — a tilted circle of radius 5.0 has a 5.0 × 3.99
footprint.
entity_getreports the WCS centre plusplane_normaland omitsradius/start_angle/end_anglerather than reporting a number that is not true in the frame the rest of the dict uses, andentity_edit_geometryrefuses withcapability: "ocs_tilted_plane", pointing atentity_move(which transforms the whole entity and preserves its plane). New capability keysocs_normalizedandocs_tilted_planeon both backends make the boundary pre-checkable viasystem_capabilities. - Named residuals (in the
ocs_normalizedreason rather than left silent): the OCS z component is not reported, HATCH boundaries are not reported at all (silence, not a wrong number), and TEXTrotationstays in the entity frame because a mirrored TEXT is mirror-imaged and no single scalar angle expresses that — measured 29.999 reported against a true WCS 150.0. - COM: ActiveX returns WCS natively for every point property except
AcDbLWPolyline.Coordinates, which is OCS; that one read is now translated. Verified only against a fake COM object — this machine has a live AutoCAD and connecting to it during development is forbidden, so the path ships unit-tested, not hardware-verified.AcDb2dPolylineis deliberately out of scope: itsCoordinatesmay be 3D triples, and changing the stride on a documentation reading alone would risk a path that works today.
- Not touched, deliberately: LINE, MTEXT, ELLIPSE, SPLINE and POINT carry
an
-
validate_pathhanded out working handles toC:\Windows. Windows names the same file through several anchors that compare unequal. The backslash spellings were rejected, butpathlibnormalises the forward-slash forms into them only after.resolve()— so//?/C:/Windows/win.inislipped past the pattern check and then failed to matchC:/Windowsin the system-directory loop. Proven: it returned a handle thatos.path.samefile'd the real file. Non-local anchors are now rejected in every spelling. This removes no capability anyone had: the equivalent backslash paths were already refused, so it only makes one policy consistent across spellings. -
The AutoLISP guard read drawing text as code. The symbol denylist scanned inside double-quoted literals, so an engineering server refused its own vocabulary:
(setq desc "Max load 500 kg")matched theloadpattern and(setq layer "C:CENTER")matched the custom-command pattern. Literals are now blanked before scanning. Every known-dangerous vector still blocks, including(load "malicious.lsp")and(vl-catch-all-apply (quote command) ...), whose dangerous token is outside the quotes. -
The command denylist matched characters, not commands. A word-boundary regex over the whole string refused
MTEXT 0,0 10,10 Do not erase this part— annotation text — while missing spellings AutoCAD accepts. SendCommand takes a macro: segments split on newline or;, and only a segment's first token is a verb. Matching is now verb-scoped with decorators (_,.,-,') stripped, the set gained the load/run channels it was missing (ARX,VBASTMT,VBAIDE,CUILOAD,SCR,SAVE,EXPORT,ETRANSMIT, …), and the refusal now names the offending verb — previously a user whose annotation was refused had no way to tell why. Single-letter aliases stay out on purpose:Eis the ERASE alias but also ZOOM's Extents option, so blocking it would refuse_ZOOM\nE\n, the canonical zoom-extents macro. -
README no longer claims the denylist is a security boundary. It said "blocked by default, with regression tests for known bypass patterns"; the bypasses above had no tests, and no denylist over a command language that loadable modules extend can be complete. It now says what the guard is — a guardrail against an agent accidentally issuing
ERASE ALL— and names the actual boundaries:ALLOWED_PATHS, not exposing the server over the network, and the typed tools. The same sentence is in the two tool docstrings, so it reaches the model that calls them. Not changed: blockingINSERT/SAVEASas command strings whileblock_insert/drawing_save_asexist was flagged as incoherent and is not — the policy is scoped to the free-text channel, and the typed tools take validated arguments where the raw commands take file paths. The reasoning is recorded insecurity.pyso it is not re-opened. -
config.settings.backendwas a control that controlled nothing._make_backendreados.environ["AUTOCAD_MCP_BACKEND"]directly, so the setting had no production reader at all andmonkeypatch.setattr(config.settings, "backend", "ezdxf")— which two tests used — read like a safety guard while doing nothing. On a developer machine with AutoCAD open that meantauto, i.e. attaching to the operator's live unsaved drawing and creating entities in it.Settings.backendis now a read-only property that reads the environment live,_make_backendgoes through it, and the test suite pins the headless backend for every test via an autouse fixture (opt out with@pytest.mark.live_backend). Assigning toconfig.settings.backendnow raisesAttributeErrorinstead of silently protecting nothing.monkeypatch.setenv— what the correct call sites already used — is unaffected. -
A capability refusal now survives the trip to the client. The backends always knew exactly why they could not do a thing — they raise
UnsupportedCapabilityError(capability, message), wherecapabilityis a key of the capability map, sosystem_capabilitiesanswers the same question up-front. None of that reached anyone:FastMCP.call_toolrewraps a non-FastMCPErrorasToolError(f"Error calling tool {name!r}: {e}") from e, and while thefrom ekeeps the cause alive in-process (which is whycad_batchcould classify refusals),__cause__does not serialise. A remote client got an English sentence, and telling "this backend cannot" from "your arguments were wrong" meant substring-matching prose. A refusal now returnsis_error: truewith{"ok": false, "kind": "unsupported", "capability", "error", "tool", "backend"}instructured_content, produced by one newCapabilityRefusalMiddleware— no tool signature changed. Not done by rebasing the exception onFastMCPError: that was measured and changes nothing a client can observe, since neither the payload nor the error flag travels with the exception class. Non-capability errors are byte-for-byte unchanged.UnsupportedCapabilityErrormoved tobackends/base.py(still importable frombackends.ezdxf_backend, and the same class object) so both backends raise one type, and the five headlesssolid_*methods now raise it instead of returning{"ok": false}— same payload, butis_errorstops being quietlyfalseon a call that did nothing. -
BREAKING (behavior):
cad_batchcounted a step that declined by value as a success. A tool that returns{"ok": false, ...}rather than raising was recordedstatus: "ok"and folded intosucceeded, so a batch could reportsucceeded: 12over work that never happened — the same class of lie as the DWG bytes and the unverified rollback. Such a step is nowstatus: "error"with kindunsupported(when it names a capability) orrefused, counts towardfailed, and honourson_errorlike any other failure. Batches that relied on, say, a no-opdrawing_redopassing silently will now stop or roll back; that is the point. Read-only tools are excluded on purpose:validation_check/drawing_critique/drawing_preflightanswer{"ok": len(issues) == 0}, so theirok: falsemeans I found something, not I could not run — reading the two the same way would let a read-only check trigger a rollback that destroys the batch's own geometry. The discriminator is thereadOnlyHintannotation each tool already publishes. -
BREAKING (behavior):
drawing_auditsaid "0 errors" about documents it had just silently rewritten — and then threw the repairs away.Drawing.audit()is not a read: it fixes every fixable problem in place, soauditor.fixesis a log of mutations already applied. The tool discarded that list entirely, never marked the document dirty — sodrawing_close(save=True)saw a "clean" document, skipped the save, and the repairs it had just made were lost — and rendered the errors it did report throughstr()on anErrorEntrythat defines no__str__, i.e. as<ezdxf.audit.ErrorEntry object at 0x...>. The tool now returnsrepaired/fixes/fix_countalongsideerrors/error_count, both as{code, name, message, handle}dicts, and marks the document dirty when it repaired something. The behavioral break: an audit that repairs now leaves unsaved changes, so a followingdrawing_close(save=True)rewrites the file where it previously did not. That is the repair finally persisting; the tool was already annotatedreadOnlyHint: false/cost: mutate. Audit can delete entities — an entity whose owner handle points nowhere is removed, not patched — and with the dirty flag fixed, that deletion now reaches disk on the next save where before it was accidentally discarded. ezdxf reports those particular fixes without the entity attached, sohandleisnullfor them; the message names the type and handle in prose. Readfixesbefore saving. On the COM backend_AUDIT Ylikewise repairs, but AutoCAD returns nothing machine-readable overSendCommand, so the counts come back asnullwithdetail: "unavailable"andcapability: "audit_detail"— never0, which would claim nothing was wrong — plus the path of the.adtlog. That backend also no longer leaves the user'sAUDITCTLpermanently set to 1. -
BREAKING (behavior): the ezdxf backend no longer writes DXF bytes into a
.dwgname.drawing_save("part.dwg")previously calleddoc.saveas()and produced a file whose contents did not match its extension — AutoCAD cannot open it, and nothing in the response said so. Both save paths now refuse, driven by the extension, sodrawing_save_as("x.dwg", fmt="dxf")is refused too. Refusals carry a machine-readable{"ok": false, "capability": "dwg"}, anddwgis now a declared capability in both backends (ezdxf: unsupported; COM: native). Save as.dxf, or use the COM backend for DWG. -
drawing_openno longer pretends it can read DWG. ezdxf detects format by content, so a DXF renamed.dwg(exactly what the bug above produced) opened "successfully" as a document that does not exist in that format, while a genuine DWG died with a rawOSErrorblaming the file rather than the backend. The tool now refuses.dwgunless the active backend declares the capability, and its parameter text no longer advertises DWG unconditionally. Workflows holding files produced by the olddrawing_savebug must rename them to.dxf; the error message says so. -
TOOL_PROFILEwas a silent no-op whenever a tool transform was attached._registered_tools()read the post-transform tool list, so with a transform in place it saw 2 tools instead of 131 — the lean profile computed 0 disabled tools andsystem_aboutreported a collapsed inventory. It now reads the underlying registry. -
ruff format --check .failed on unmodifiedmain. Ruff 0.16 began formatting Python code blocks inside Markdown, so the committeddocs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.mdstarted failing the format gate with no source change. Reformatted the file and pinned the CI ruff version so a future release cannot re-break the gate the same way. -
release.ymlnow runsruff format --check .alongsideruff check ., matching the CI lint job — the release gate previously skipped formatting. -
Every headless diameter and radius dimension reported the wrong number, at default settings.
add_diameter_dim/add_radius_dimmeasure to the point they are given;leader_lengthis a text placement. Passingcentre + (radius + leader)as that point made the leader part of the measurement, so a true ⌀40 bore was dimensioned 60 with the defaultleader_length=10, and 108 at 34. Radius callouts wereleader_lengthtoo large the same way. This is the worst class of defect for this project — a wrong number, silently, on the one part of a drawing that exists to carry numbers — and it shipped in v1.4.0 and earlier. Now the size comes fromradiusandlocationonly places the text. The live COM backend was never affected: ActiveX'sAddDimDiametrictakes the two chord points and the leader length as separate arguments, so the two engines had been disagreeing by2 × leader_lengthon the same call. Found while building the sheet in the README's hero image, whose own bore callout was wrong. Two checks added tocorrectness_suite.py(26, was 24); both reportfail → passagainst v1.4.0 rather thanmiss → pass, which is what distinguishes a repaired defect from new capability in that lane. -
A HATCH could not be measured, and the refusal listed HATCH as measurable.
entity_measurehad no HATCH branch, so a hatch fell through to the generic error — whose own text named HATCH among the measurable types. Whichever half was right, the pair could not both be. It now walks the boundary paths and reports the area the hatch actually fills: outer loops minus the islands inside them, 300 rather than 400 on a 20×20 square with a 10×10 island. Nesting depth is computed by containment rather than read off the boundary flags, because DXF marks a path external or outermost and never "two levels down" — and depth is exactly where AutoCAD's three island styles stop agreeing.hatch_stylecomes back with the number so it can be read against the drawing's own setting. On a curved edgeflatten_toleranceis not the accuracy knob it looks like: ezdxf hands boundaries over as cubic Beziers, so tightening it converges on the Bezier's area, ~0.028% above the circle it stands for, which is whyexactgoes False and stays False. -
boundary_traceandentity_measuredisagreed about the same shape. The boundary tools reported an area taken from a copy of the loop flattened at a fixed 0.01 sag whileentity_measureread the stored bulges analytically: 139.2177 against 139.2699 for one figure, with nothing in either payload marking one as the approximation. The polyline the boundary tools store carries exact bulges, so the exact answer was always available; both now read it and round to the same six decimals. The flattened figure survives only where it is harmless — ranking candidate loops, where a 0.01% error cannot change which is smallest. -
A circle stored as two bulged vertices measured 0.0. Two semicircular arcs joined into a circle store as a two-vertex closed LWPOLYLINE with both bulges 1.0 — what AutoCAD writes for a JOINed pair of arcs, and what
boundary_from_entitiesbuilds from any two-arc loop.polygon_area_perimetertreated fewer than three vertices as degenerate and returned zero, when the shoelace over two points is genuinely 0 and the two circular segments are the entire shape. Only zero or one vertex bounds nothing now. -
The v1.4 → v1.5 performance comparison was measured, and it is not flattering. The published perf report was recorded on CPython 3.14 and the new one on 3.11.15, which alone accounts for nearly all of an apparent 2–6× "speedup". Re-running v1.4.0 in a worktree on the same interpreter with the same suite file gives the real picture: entity creation is 28% slower (245 → 315 ms for 2,000 lines) and the 10,000-line roundtrip 22% slower. Fully attributed —
EZDXF_CALL_TIMEOUT=0returns v1.5.0 to 244.8 ms and 1,679 ms, v1.4.0's numbers exactly. The whole cost is the per-callasyncio.wait_forthis release wrapped around every headless call so that one hung ezdxf call can no longer wedge a server whose document lock is a singleasyncio.Lock. Deliberate trade, documented knob, and on the 1.6 roadmap to win back.
The five items carried into this release from 1.4 are all done: screenshot
overlay + handle grounding, ezdxf undo/redo, COM block_create_from_entities,
ISO 286 transition/interference holes, and titleblock on paper-space layouts.
ISO 286 shafts r/s/t/u are deliberately not done — they need table data
nothing here can derive, and the refusal says so rather than guessing.
[1.4.0] — 2026-07-23
Release infrastructure + the roadmap features shipped together. 474 tests, Ruff lint- and format-clean. Tool count: 122 → 131.
Added
- CI restored and extended (
.github/workflows/ci.yml): ruff lint/format, Linux test matrix (3.11/3.12) with coverage, a Windows leg running the mocked-COM suite on the platform the COM backend targets, a package job (build + twine check + clean-venv wheel smoke incl.autocad-mcp --help), a Docker build/smoke job, and MCPserver.jsonschema validation. - Release pipeline (
.github/workflows/release.yml): tagv*→ test gate → tag/version match check → build → PyPI trusted publishing (OIDC) → GitHub Release with artifacts. - MCP registry manifest
server.json(schema 2025-12-11, nameio.github.u-c4n/autocad-mcp, PyPI package) plus themcp-nameownership marker in the README and anautocad-mcp-proconsole alias souvx autocad-mcp-proworks directly. - Live competitor benchmark lane: a generic black-box MCP-stdio driver
(
benchmarks/adapters/mcp_stdio.py), reproducible pinned checkouts (benchmarks/competitors_env.py), and adapters for puran-water/autocad-mcp and beiming183-cloud/AutoCAD-MCP that run the same 10-task matrix over stdio with harness-side DXF verification. Published results (benchmarks/results/published/): autocad-mcp-pro 100.0, beiming183 50.0, puran-water 45.0; rendered todocs/assets/autocad-mcp-livebench.svg. Weekly/dispatch CI workflow. - Tool profiles (
TOOL_PROFILE=lean|core|full): capability-aware discovery applied in the server lifespan —leanis a curated ~46-tool drafting core,corehides raw escape hatches and long-tail tools,full(default) exposes everything. Reported bysystem_about. - Paper space / layouts:
layout_list,layout_create,layout_set_current,viewport_create(scaled model viewports) on both backends, anddrawing_export_pdf(layout=...)for plotting a layout. Headless limitation is explicit: viewport model-content projection is COM-only (viewport_rendercapability). - ISO 286 limits and fits (
engineering/fits.py): authored table data (IT4–IT11, sizes 1–500 mm; shafts d/e/f/g/h/js/k/m/n/p, holes D/E/F/G/H/JS) withfit_lookup("H7", 20.0);dimension_linear/radius/diametergained afitparameter that resolves deviations from the measured nominal and appends the fit code to the dimension text. Out-of-scope letters raise a clear error naming the supported set. - Opt-in native 3D solids (
ENABLE_3D=true, COM only):solid_box,solid_cylinder,solid_extrude,solid_revolve,solid_boolean. Hidden from discovery and rejected while disabled; ezdxf reports the honestsolid_3dcapability boundary (ACIS cannot be generated headlessly). - Release-consistency test suite (
tests/test_release_consistency.py): pyproject ↔ version.py ↔ CHANGELOG ↔ README snapshot ↔ server.json versions, README tool/resource/prompt counts vs live registrations, per-section header counts vs decorators, Dockerfile COPY vs wheelonly-include, and the READMEmcp-namemarker.
Changed
- pyproject metadata completed for PyPI: SPDX license + license file, authors, keywords, classifiers, project URLs.
build_dim_override:deviation/limitmodes now pass DIMTM signed (-lower) instead ofabs()— double-positive ISO 286 fits (e.g. p6 +0.035/+0.022) render correctly; the legacy "positive magnitude = minus deviation" contract is unchanged.- Capability maps:
paper_spaceis now native on both backends; newviewport_renderfeature (COM native / ezdxf unsupported);solid_3dreflects the ENABLE_3D gate on COM. - README rebuilt around the live-run benchmark lane; CI/PyPI badges added;
ruff formatapplied repo-wide and enforced in CI.
Fixed
- Dockerfile shipped a broken image:
engineering/andversion.pywere never copied, so engineering tools failed in containers. Both are now copied (plus README/LICENSE for metadata) and the healthcheck imports the engineering package. server.pysection-header tool counts and the CLAUDE.md inventory were stale (six sections drifted); both now match the live surface and are locked by tests. Removed the reference to the non-existent.claude/skills/directory.
[1.3.0] — 2026-07-17
Closed-loop quality, production annotation, and auditable delivery. 415 tests, Ruff lint-clean. Tool count: 116 → 122.
Added
drawing_preflightnormalizes production requirements, reports missing or conflicting facts before geometry starts, and emits a deterministic SHA-256 spec hash thatdrawing_plancan enforce.drawing_refineruns a boundedcritique → repair → re-critiqueloop. Each repair round is isolated in its own transaction and rolls back if score regresses or hard-error count increases. Construction, duplicate, layer color/lineweight, untrimmed endpoint, and dimension-overlap repairs are supported; undefined GD&T datums remain explicitly manual.- TABLE and MLEADER semantics via
entity_create_tableandleader_create_mleader: native ActiveX entities on COM and deterministic, portable LINE/LWPOLYLINE/MTEXT composites on ezdxf. Composite creation returns child handles and a logical ID; after DXF reopen, inspection uses the persisted standard child entities rather than a native TABLE/MLEADER object. system_capabilitiesand shared typed backend capability maps distinguish native, rendered, composite, snapshot, shared, and unsupported features.drawing_delivercreates DXF/PDF/PNG bundles, runs validator + critique, re-opens the canonical DXF for entity/type/layer/bounds parity, hashes every artifact, and writesmanifest.jsonplusvalidation.json. Failed gates keep artifacts for diagnosis but never report delivery success.- Benchmark v2: ten fixed vendor-neutral tasks, an adapter interface, an ezdxf/COM reference adapter, cooperative per-task timeout reporting, fixed-matrix coverage-aware scoring, machine-readable statuses, runtime/environment/capability metadata, and artifact SHA-256.
Changed
- ezdxf transaction snapshots are isolated from user-facing undo history, so a refiner rollback cannot consume an unrelated undo entry.
- Package metadata is the canonical version source used by
system_aboutand delivery manifests. benchmarks/correctness_suite.pynow shows usage for argless/--helpcalls instead of raising a traceback.
Fixed
- Install from source —
pip install -e ".[full]"failed at "Preparing editable metadata" because the flat layout has no directory matching the project name, so hatchling could not infer which files to ship.pyproject.tomlnow declares the wheel file selection explicitly (server.py,config.py,security.py,backends/,engineering/). (#2) dimension_linearon the COM backend — always crashed with<unknown>.AddDimLinear: the AutoCAD ActiveX API has noAddDimLinearmethod. Linear dimensions are now created viaAddDimRotated(identical argument order), with a mocked regression test. (#3)
[1.2.0] — 2026-07-07
Production-ISO parity + a measurable quality moat. 360 tests, ruff-clean. Tool count: 111 → 116.
Added
drawing_settings— read or change common AutoCAD drawing settings by friendly name (units mm/cm/m/inch/feet, linear/angular precision, LTSCALE, DIMSCALE, text size, point mode/size, OSMODE, fillet radius) without memorising system-variable names. Call with no argument for a full snapshot. A convenience facade oversystem_get_variable/system_set_variable; cross-backend.- In-place editing —
entity_edit_textre-labels or resizes an existing TEXT/MTEXT entity, andentity_edit_geometryre-drives a CIRCLE/LINE/ARC (center, radius, endpoints, arc angles) — both preserve the entity handle, so the user can adjust a drawing without delete-and-recreate. Cross-backend. - 2D GD&T (ISO 1101 / ASME Y14.5) —
gd_framedraws a feature control frame (all 14 geometric characteristics, ⌀ zone prefix, Ⓜ/Ⓛ/Ⓢ material modifiers, multi-datum references) anddatum_featureplaces a datum symbol. Frames are composed from LINE + TEXT so they render identically on both the COM and ezdxf backends (ezdxf's native TOLERANCE entity renders blank via matplotlib). No competing CAD MCP or surveyed text-to-CAD product ships 2D GD&T authoring. - GD&T datum-consistency gate — a new
gdtcritique focus failsdrawing_finalizewhen a feature control frame references a datum with no matching datum feature (a meaningless FCF per ISO 1101). - ISO 129 dimension tolerances —
dimension_linear/dimension_radius/dimension_diametergaintol_upper/tol_lower/tol_mode(symmetric±,deviation+a/-b,limitstacked,basicboxed) and atext_override(e.g.⌀20 H7). Toleranced production dimensions are now possible — previously the single biggest functional gap. - Scalar drawing-score + invalidity ratio (
engineering/scoring.py) — thedrawing_finalizepayload now carries a 0-100score, aninvalidity_ratio, and an A-Fgradeover the union of the structural validator and the premium critique, so drawing quality is regression-trackable (MUSE / CadBench grade an Invalidity Ratio, not shape).
Fixed
- Honesty: the COM backend advertised a false
all_entity_typescapability (it authors 2D entities only, no 3D solids) — corrected toentities_2d.
[1.1.1] — 2026-06-20
Added
selection_get(Entity Query, COM backend): reads AutoCAD's implied ("pickfirst") viewport selection — the entities the user highlighted with grips before invoking the AI — and returns their handles +EntityInfo. This lets the AI scope work to exactly what the user picked (dimension_auto(selection_get()["handles"])) instead of acting on the whole drawing. Resolves #1 — the layer-juggling workaround is no longer needed. Surfaces thePICKFIRSTsysvar state so an empty selection is self-explanatory. The ezdxf headless backend has no viewport, so it returnsok=Falsewith an empty handle list (same shape, never raises). Tool count: 110 → 111.
[1.1.0] — 2026-06-19
Correctness, cross-backend parity, and an enforced quality gate, landed across four audited
sprints (see docs/analysis/). 318 tests, ruff-clean.
Added
- Premium drafting workflow (shared across both backends):
drawing_plan,drawing_critique(ISO-128 focuses),point_from_snap,drawing_apply_iso_layers,dimension_auto,entity_select_smart,construction_xline/construction_clear. - Deterministic geometry for exact OSNAP coordinates:
point_intersection(line/line, line/circle, circle/circle) andpoint_tangent(external point → circle). - Engineering / deterministic CAD layer: involute gear front view + section A-A,
DIN 6885 keyed bore, ISO A3 title block, and the 8-step
drawing_finalizevalidator. - HTTP bearer-token auth:
StaticTokenVerifierwired into FastMCP whenMCP_AUTH_TOKENis set. - Mocked-COM test harness (
tests/test_com_backend.py+ Sprint-3/4 suites) — COM logic is now regression-tested on Linux CI without a live AutoCAD. entity_create_mtextrotation parameter;dimension_autolayer override;bounding_box/ ARClength/ TEXT-MTEXTrotation+char_heighton both backends;BlockInfo.description.- Security module (
security.py), centralized config (config.py),.env.example, path validation, command/LISP sanitization, ruff config, pre-commit hooks, pytest-cov.
Changed
drawing_finalizenow enforces the premium critique in addition to the structural validator: leftover construction geometry, non-ISO-128 lineweights, untrimmed corners, duplicate entities, and dimension overlap block the gate (was advisory-only).strict_critique=Truefails on warnings too.drawing_save_asderives the on-disk format from the file extension —part.dxfwrites DXF, not DWG. ezdxf refuses to mislabel a.dwgfile.- Dimension and construction layers resolve from the active layer set (iso13567 →
M-DIMEN-T-N/M-CONST-E-N, not the hardcodedDIM/CONSTRUCTION). - Premium meta-tools lifted into the shared
AutoCADBackendbase class (single source of truth). - Dead code removed (
section.py,generate_tooth_profile,CommandResult,set_layer_active, …).
Fixed
- ezdxf
dimension_aligned/dimension_angularraisedTypeError(wrong ezdxf 1.4 args) — fixed. - Full-circle polar array placed a duplicate copy over the original — fixed (divisor = count).
entity_offsetignoredside_x/side_yon both backends — now honored (and COM no longer leaks extras).- COM
entity_create_hatchbuilt an associative hatch then deleted its boundary — now non-associative. - Lineweight mm-vs-hundredths truncation wiped ISO-128 weights — fixed via
normalize_lineweight. view_screenshot/drawing_export_pdfcould SIGSEGV (matplotlib GUI backend in a worker thread) — now render headless viaAgg(Figure+FigureCanvasAgg).entity_select_smartlength_rangesilently rejected all ARCs (nolength) — fixed.- Gear tooth profile self-overlapped for high tooth counts (z ≥ ~42); section view drew duplicate bore lines; validator keyway heuristic was a permanent false-negative — all fixed.
- COM
system_run_lispalways reported"nil";system_set_variabledidn't coerce numeric sysvars — fixed.system_abouttool groups /_registered_tool_countno longer drift or surface-1. drawing_newbootstrap failures now surface asdegradedinstead of reporting success.
Security
- HTTP remote-bind guard now fires on every launch path (including
fastmcp run server.py:mcp), not only the__main__block — closing an anonymous-remote-bind gap. - AutoLISP allowlist bypass-vector regression tests (newline injection, symbol aliasing,
vla*/acet-*,c:custom commands). - COM apartment leak bounded (
CoUninitializeon teardown); transaction commit/rollback andsystem_run_lispnow respect the CMDACTIVE guard.
[1.0.0] — 2026-03-01
Added
- Initial release with 67 tools, 6 resources, 5 prompt templates
- Dual-engine architecture: COM backend (live AutoCAD) + ezdxf backend (headless)
- FastMCP 3.0 server with middleware stack (error handling, audit, timing, logging)
- Drawing management: new, open, save, save-as, export DXF/PDF, purge, audit, undo/redo
- Entity creation: line, circle, arc, polyline, rectangle, text, mtext, hatch, spline, ellipse, point, block reference
- Dimensions: linear, aligned, angular, radius, diameter
- Entity modification: move, copy, rotate, scale, mirror, offset, delete, rectangular/polar array
- Layer management: create, delete, set current, modify, freeze/thaw, lock/unlock, hide/show, isolate
- Block operations: list, insert, explode, attributes, create from entities, find references
- Analysis: entity stats, region search, distance/area measurement, bounding box, select by layer/type
- View control: zoom extents/window, screenshot
- Transaction support: begin, commit, rollback
- System tools: status, variables, command execution, AutoLISP evaluation
- 5 prompt templates: floor plan, P&ID, electrical schematic, mechanical drawing, quick drawing