docs: v1.6 roadmap decomposition and the Track A (P&ID) design spec

Research snapshot of the AutoCAD MCP field (20 READMEs, 2026-09-14), the
seven 1.6 tracks with measurable acceptance criteria, their order
(A -> E -> B -> G -> F -> H -> D), and the tool-pack decision.

Track A: symbol catalogue authored in code (ISO 10628-2 + ISA-5.1), two
generic contracts (block_define, entity_get/set_xdata), nine pid_* tools,
routing, the ISA-5.1 tag grammar, the pid_graph reader, six critique
focuses, TOOL_PACKS, and the test/evidence plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-15 10:07:23 +03:00
co-authored by Claude Opus 5
parent abc2a82e71
commit 3430573de1
2 changed files with 1049 additions and 0 deletions
@@ -0,0 +1,814 @@
# v1.6 Track A — P&ID expert and reader
**Date:** 2026-09-15 · **Status:** design approved in conversation, awaiting
written review · **Parent:** `2026-09-15-v1.6-roadmap-design.md` (track A) ·
**Baseline:** v1.5.1
## 1. Summary
v1.5.1 supports P&IDs with a prompt template that tells the model to hand-draw a
pump as a circle plus a triangle — the "no coordinate guessing" rule, broken by
our own prompt. Three rival servers ship a `pid` tool, all bound to the
commercial CAD Tools Online symbol library.
This track makes the server a P&ID **expert** (a symbol catalogue authored in
code, real blocks with tags and ports, orthogonal line drawing with ISA-5.1 line
classes and line numbers, an ISA-5.1 tag grammar) and a P&ID **reader**
(`pid_graph` turns any P&ID — ours or foreign — into a connectivity graph, and
derives the instrument index, line list and equipment list from it). Six
critique focuses make P&ID correctness part of `drawing_finalize`'s score.
Two generic contracts are added because the catalogue needs them and nothing in
v1.5.1 provides them: `block_define` (a block definition with ATTDEFs from typed
primitive specs) and `entity_get_xdata` / `entity_set_xdata`.
Tool budget: **+12** (3 generic, 9 `pid_*`) → 166 registered.
## 2. Scope
**In:** everything in sections 4–13. **Out of 1.6** (recorded so nobody
re-derives it): DEXPI/Proteus export (pyDEXPI is AGPL; writing Proteus XML from
scratch is its own project), ISA-5.2 binary-logic symbols, obstacle-avoiding
routing, off-page links across *files* (same-file links are in), electrical
IEC 60617 symbols (1.7, on this infrastructure), reading DWG headlessly (DWG is
read by the live backend; headless reads DXF).
## 3. Architecture
```
engineering/pid/
__init__.py public surface re-exported by engineering/__init__.py
symbols.py SymbolSpec catalogue (pure data + generators), ports, naming
geometry.py primitive-spec helpers shared by symbols (arcs, polygons, text)
tags.py ISA-5.1 grammar, letter tables, equipment-prefix map
lines.py line classes, routing candidates, labels, markers, arrows
xdata.py ACADMCP_PID payload encode/decode (chunking)
graph.py PidGraph builder: nodes/edges/junctions/dangling, foreign heuristics
deliverables.py instrument index, line list, equipment list rows (+CSV)
spec.py pid_from_spec schema validation and execution
critique.py the six pid_* focuses (registered into engineering.critique)
backends/contracts/blocks.py + block_define
backends/contracts/entity_query.py + entity_get_xdata / entity_set_xdata
backends/ezdxf_backend.py, backends/com_backend.py implementations
server.py SECTION 17: P&ID (9 tools) + 3 generic tools in
their existing sections; TOOL_PACKS; 2 resources
discovery/aliases.py 12 alias records
```
Data flow for drawing: `pid_symbol_insert` → catalogue → `block_define` (once
per block name per document) → `block_insert(attributes=…)` → `entity_set_xdata`
→ returns handle + WCS ports. `pid_line_draw` → resolve ports → routing →
`entity_create_polyline` on the class layer → label `entity_create_text` →
marker/arrow `block_insert` → `entity_set_xdata`.
Data flow for reading: `pid_graph` → `entity_list` (paged, one pass) +
`block_get_attributes` per INSERT + `entity_get_xdata` per candidate →
classification → spatial index → endpoint resolution → graph → deliverables and
critique consume the graph, never the drawing directly.
Every geometry write goes through existing backend primitives, so both engines
get the whole track for the price of the two new contracts.
## 4. Symbol catalogue (`engineering/pid/symbols.py`)
### 4.1 Standards
- Instrumentation (bubbles, signal line classes, identification letters):
**ISA-5.1-2009**.
- Equipment, valves, fittings: **ISO 10628-2:2012** shapes. Where ISO 10628-2
has no symbol (e.g. spectacle blind), the PIP PIC001 shape is used and the
record's `source` says so.
- Every `SymbolSpec` carries `source: str` naming the standard and figure/table
it was authored from. A test asserts the field is non-empty and matches
`^(ISO 10628-2|ISA-5\.1|PIP PIC001)`.
### 4.2 Units and conventions
- Symbol-local coordinates in **millimetres at A3/A1 paper scale**; the
insertion `scale` parameter handles other sheets. Reference sizes:
instrument bubble Ø10; valve body 8 × 4 (two triangles meeting at the
centre); pump circle Ø8; shell-and-tube exchanger 24 × 8; default vertical
vessel 20 × 40; grid 5. Text: equipment/valve `TAG` height 2.5, bubble
`FUNC`/`LOOP` height 2.0 (ISO 3098 sizes).
- Origin `(0, 0)` = insertion point = centre of the body's bounding box.
Rotation 0 = flow left→right (process `in` port on −X, `out` on +X).
- All primitives inside the block are on layer `"0"` with colour/linetype/
lineweight **ByBlock**, so the INSERT's layer (section 4.6) supplies the ISO
128 lineweight. Filled shapes (check-valve flap, flow arrow) are closed
polylines plus a SOLID-pattern hatch spec; `block_define` supports
`{"type": "solid", "points": [...]}` for exactly this.
### 4.3 Record structure
```python
@dataclass(frozen=True)
class Port:
name: str # "in", "out", "suction", "discharge", "N1", "signal"
x: float
y: float
direction_deg: float | None # outward direction a line leaves along; None = radial
kind: Literal["process", "signal"]
radius: float = 0.0 # radial ports only (bubbles): line starts on the circle
@dataclass(frozen=True)
class SymbolSpec:
name: str # block name, see 4.5
family: Literal["valve", "rotating", "vessel", "heat", "misc", "instrument", "connector", "marker"]
primitives: tuple[dict, ...] # block_define entity specs (section 5.1)
attdefs: tuple[dict, ...] # block_define attdef specs
ports: tuple[Port, ...]
bbox: tuple[float, float, float, float] # xmin, ymin, xmax, ymax — declared, tested
layer_class: Literal["PROCESS-VALVES", "PROCESS-EQUIPMENT", "INSTRUMENT-SYMBOL", "line"]
source: str
parametric: bool = False
params: dict = field(default_factory=dict) # the parameters a parametric spec was built from
```
`CATALOG_VERSION = "1"` is written into every INSERT's XDATA. A change to any
shipped symbol's geometry or ports bumps it; the graph builder reads geometry
from the drawing, not the catalogue, so old drawings keep working.
### 4.4 Catalogue v1
Base symbols (41) — every one is a test case:
| Family | Symbols | Ports |
|---|---|---|
| valve (11 bodies) | `gate`, `globe`, `ball`, `butterfly`, `check`, `needle`, `plug`, `diaphragm`, `three_way`, `angle`, `relief` | `in`(−X), `out`(+X); `three_way` adds `branch`(−Y); `angle`: `in`(−X), `out`(−Y); `relief`: `in`(−Y), `out`(+X) |
| valve actuators (6, composed onto any body except `check`/`relief`) | `none`, `diaphragm`, `piston`, `motor`, `solenoid`, `hand` | adds `signal`(+Y) for every actuator except `none`/`hand`; fail position attribute `FAIL` ∈ {"", "FO", "FC", "FL"} drawn as text beside the actuator |
| rotating (6) | `centrifugal_pump`, `pd_pump`, `compressor`, `fan`, `agitator`, `ejector` | pumps: `suction`(−X), `discharge`(+Y); compressor/fan: `in`(−X), `out`(+X); agitator: `shaft`(−Y, process, mounts on a vessel); ejector: `motive`(−X), `suction`(−Y), `out`(+X) |
| vessel (6, parametric) | `vertical_vessel`, `horizontal_vessel`, `tank` (`roof` ∈ {flat, cone}), `column` (`trays` int ≥ 0), `reactor` (`jacketed` bool), `hopper` | nozzles from `params["nozzles"] = [{"name","side","fraction"}]`, side ∈ {top, bottom, left, right}, fraction ∈ [0,1] along that side; defaults: vertical_vessel N1 top, N2 bottom, N3 left 0.7, N4 right 0.3 |
| heat (4) | `shell_tube_hx`, `plate_hx`, `air_cooler`, `fired_heater` | shell: `shell_in`(+Y), `shell_out`(−Y), `tube_in`(−X), `tube_out`(+X); others: `in`/`out` per side |
| misc (7) | `filter`, `strainer_y`, `reducer` (`concentric`/`eccentric`), `flange_pair`, `spectacle_blind`, `orifice_plate`, `drain_vent` | inline: `in`(−X), `out`(+X); `orifice_plate` adds `signal`(+Y); `drain_vent`: `in`(+Y) only |
| instrument (1 generator → 20) | `type` ∈ {`discrete`, `dcs`, `computer`, `plc`} × `location` ∈ {`field`, `primary`, `auxiliary`, `primary_rear`, `auxiliary_rear`} | one radial `signal` port, radius 5 |
| connector (3) | `offpage` (`direction` ∈ {in, out}), `tie_in`, `spec_break` | `offpage`: one `process` port on the open side; `tie_in`/`spec_break`: `in`/`out` |
| marker (5) | `arrow_flow`, `mark_pneumatic` (//), `mark_capillary` (X), `mark_hydraulic` (L), `mark_data` (o) | none — markers are decoration on a line and never graph nodes |
Instrument bubble by ISA-5.1-2009 Table 5.4.1: `discrete` = circle;
`dcs` = circle inside a square; `computer` = hexagon; `plc` = diamond inside a
square. Location line through the middle: `field` none; `primary` one solid;
`auxiliary` two solid; `primary_rear` one dashed; `auxiliary_rear` two dashed
(dashes drawn as short line primitives, 1 mm on / 1 mm off, so no linetype
dependency inside the block).
### 4.5 Block naming
`PID_<FAMILY>_<SYMBOL>[_<VARIANT>]`, upper case, e.g. `PID_VALVE_GATE`,
`PID_VALVE_GATE_DIAPHRAGM`, `PID_ROTATING_CENTRIFUGAL_PUMP`,
`PID_INST_DCS_PRIMARY`, `PID_CONNECTOR_OFFPAGE_OUT`, `PID_MARKER_ARROW_FLOW`.
Parametric vessels: `PID_VESSEL_VERTICAL_<8 hex>` where the hex is the first 8
characters of SHA-256 over the canonical JSON of `params` — identical
parameters share one definition, different ones never collide in practice.
### 4.6 Layers
INSERTs go on `layer_class`: valves → `PROCESS-VALVES`, rotating/vessel/heat/
misc/connector → `PROCESS-EQUIPMENT`, instruments → `INSTRUMENT-SYMBOL`,
markers → the layer of the line they decorate. `PID_LAYERS` gains
`("PROCESS-LINE-TEXT", 7, "Continuous", 0.25, "line numbers")` as its 16th
entry. `pid_symbol_insert` and `pid_line_draw` create a missing target layer
from the `PID_LAYERS` definition (idempotent, same code path as
`drawing_apply_iso_layers`) and report it in `layers_created`; they do not
apply the whole set.
### 4.7 ATTDEFs
| Family | Tags | Placement (symbol-local) |
|---|---|---|
| valve, rotating, vessel, heat, misc, connector | `TAG` (height 2.5, centred at `(0, ymax + 3.5)`); `DESC` (height 2.0, centred at `(0, ymax + 6.5)`, visible; `pid_symbol_insert` writes `""` when no description is given — an empty ATTRIB renders nothing, and per-insert invisibility is not something `block_insert(attributes=…)` exposes) | above the body |
| valve with actuator | + `FAIL` (height 2.0, right of the actuator) | |
| instrument | `FUNC` (height 2.0, middle-centre at `(0, +1.25)`), `LOOP` (height 2.0, middle-centre at `(0, −3.25)`) | the two halves of the bubble |
| offpage | `TAG` inside the arrow box; `LINK` invisible | |
Attribute values are what `block_get_attributes` returns and what the reader
uses first; XDATA (section 5.2) duplicates the machine-readable part.
## 5. Generic contracts
### 5.1 `block_define`
```python
@abstractmethod
async def block_define(
self,
name: str,
entities: list[dict],
attdefs: list[dict] | None = None,
base_x: float = 0.0,
base_y: float = 0.0,
overwrite: bool = False,
) -> dict:
"""→ {"ok": True, "name", "entity_count", "attdef_count", "replaced": bool, "backend"}"""
```
Entity specs (all coordinates block-local; `layer` optional, default `"0"`;
`color`/`linetype`/`lineweight` optional, default ByBlock):
| `type` | Required keys |
|---|---|
| `line` | `x1, y1, x2, y2` |
| `circle` | `cx, cy, r` (r > 0) |
| `arc` | `cx, cy, r, start_deg, end_deg` (CCW, r > 0) |
| `polyline` | `points: [[x, y], ...]` (≥ 2), `closed: bool`, optional `bulges: [float, ...]` same length as `points` |
| `text` | `text, x, y, height` (> 0), optional `rotation_deg` (0), `align` ∈ {`left`, `center`, `right`, `middle_center`} (`left`) |
| `solid` | `points` (3 or 4) — written as a closed polyline plus a SOLID hatch |
ATTDEF specs: `tag` (`^[A-Z][A-Z0-9_]{0,30}$`, unique within the block),
`prompt` (default = tag), `default` (default `""`), `x, y, height`, optional
`rotation_deg`, `align` (as text), `invisible: bool` (False).
Rules, all tested:
- The whole request is validated before anything is written (the
`hatch_add_boundary` pattern). An invalid entry raises `TypeError` naming the
index and the key; no block is created or modified.
- `entities` empty → `ValueError` ("nothing to define") — a definition with
only attributes is refused too, because it draws nothing.
- Name: `sanitize_symbol_name(name, kind="block")` (existing helper);
anonymous names (`*U…`) refused.
- Name exists and `overwrite=False` → `ValueError` naming the block and the
flag. `overwrite=True` **clears the existing definition's entities and
re-adds** rather than deleting the block, so existing INSERTs keep pointing
at the name and now show the new geometry; `replaced: true` says so. ezdxf:
`blk.delete_all_entities()`; COM: iterate and `Delete()` each entity in the
Block object. (Deleting a block that has references is refused by both
engines; replacing contents is the semantics that works on both.)
- ezdxf: `doc.blocks.new(name, base_point=…)`, `add_line/add_circle/add_arc/
add_lwpolyline/add_text/add_attdef/add_hatch`. COM: `Blocks.Add(pt, name)`,
then `AddLine/AddCircle/AddArc/AddLightWeightPolyline/AddText/AddAttribute/
AddHatch` on the Block object; `AddAttribute(height, mode, prompt, pt, tag,
value)` with `mode = acAttributeModeInvisible (1)` for invisible ATTDEFs,
else `acAttributeModeNormal (0)`; text alignment through `Alignment` +
`TextAlignmentPoint`.
- Both engines: abstract (no `@capability`) — both can do it.
Registered as `block_define` in SECTION 7 (Blocks), tags `{"block", "create"}`,
cost `mutate`, aliases `acad=("BLOCK", "BEDIT", "ATTDEF")`.
### 5.2 `entity_get_xdata` / `entity_set_xdata`
```python
@abstractmethod
async def entity_get_xdata(self, handle: str, app_name: str | None = None) -> dict:
"""→ {"handle", "xdata": {app: [value, ...]}, "backend"}; unknown app → {} not an error"""
@abstractmethod
async def entity_set_xdata(self, handle: str, app_name: str, values: list) -> dict:
"""→ {"ok": True, "handle", "app_name", "value_count", "bytes", "backend"}"""
```
- `app_name`: `^[A-Za-z_][A-Za-z0-9_]{0,30}$`; registered in the APPID table if
missing (`doc.appids.add` / `RegisteredApplications.Add`).
- Value typing by group code: `str` → 1000, `int` → 1071, `float` → 1040,
`[x, y]` or `[x, y, z]` → 1010; anything else → `TypeError`. Strings longer
than 255 characters → `ValueError` (DXF limit; callers chunk). Total encoded
size > 16 KB → `ValueError` (AutoCAD limit) before any write. `values` empty
→ the app's XDATA is **removed** (documented, tested).
- `set` replaces the app's XDATA wholesale; other apps' XDATA untouched.
- ezdxf: `entity.set_xdata / get_xdata / discard_xdata`. COM: `SetXData(types,
values)` / `GetXData(app, types, values)` with variant arrays.
- Both engines: abstract.
Registered in SECTION 5 (Entity Query): `entity_get_xdata` (tags `{"entity",
"query"}`, cost `read`, aliases `acad=("XDATA", "XDLIST")`), `entity_set_xdata`
(tags `{"entity", "modify"}`, cost `safe`).
`engineering/pid/xdata.py` encodes the P&ID payload as
`["json", chunk1, chunk2, ...]` (≤ 255 chars each) under app `ACADMCP_PID` and
decodes by joining every 1000-string after the `"json"` marker. Payloads:
```json
{"v": 1, "kind": "symbol", "catalog": "1", "name": "PID_VALVE_GATE_DIAPHRAGM",
"family": "valve", "symbol": "gate", "actuator": "diaphragm",
"params": {}, "ports": {"in": [-4, 0, 180], "out": [4, 0, 0], "signal": [0, 9, 90]}}
{"v": 1, "kind": "line", "class": "process_major", "number": "100-P-1001-CS1-IH",
"size": "100", "service": "P", "spec": "CS1", "insulation": "IH",
"from": {"handle": "2F", "port": "discharge"}, "to": {"handle": "31", "port": "N1"}}
{"v": 1, "kind": "marker", "line": "3A"}
```
Ports in XDATA are symbol-local; the reader applies the INSERT transform. They
are written for every symbol so a reader without the catalogue (another tool,
a future version) still sees them.
## 6. Tools (SECTION 17: P&ID, 9 tools)
All in pack `pid` (section 12). Tags `{"pid", ...}`; `pid` is appended to
`_GROUP_TAG_PRIORITY` ahead of `entity` so `system_about` files them under one
`pid` group.
### 6.1 `pid_symbol_list(family: str | None = None) -> dict`
Catalogue listing: `{"catalog_version", "symbols": [{"symbol", "family",
"variants", "ports": [...], "parametric", "params_schema", "source"}]}`.
Cost `read`. Also exposed as resource `autocad://pid/symbols` (section 13).
### 6.2 `pid_symbol_insert(...)`
```python
pid_symbol_insert(symbol: str, x: float, y: float, rotation: float = 0.0, scale: float = 1.0,
tag: str | None = None, desc: str | None = None,
actuator: str | None = None, fail: str | None = None,
type: str | None = None, location: str | None = None, # instruments
params: dict | None = None, # parametric
link: str | None = None, # offpage
layer: str | None = None)
→ {"handle", "block_name", "defined": bool, "layers_created": [...],
"ports": {"in": {"x","y","direction_deg","kind"}, ...}, # WCS
"tag": str | None, "tag_warnings": [...], "backend"}
```
- Instruments take `tag="FIC-101"` and split it through the grammar into
`FUNC`/`LOOP` (area prefix and suffix kept in `LOOP` text as written, e.g.
`101A`); or `func`/`loop` may be given in `params`. An invalid tag is
**written and warned** (`tag_warnings`), never refused — critique catches it.
- Unknown `symbol`/`actuator`/`type`/`location` → `ValueError` listing the
valid values. `actuator` on `check`/`relief` → `ValueError`. `params` on a
non-parametric symbol → `ValueError`; parametric symbols validate params
(sizes > 0, nozzle sides/fractions in range, unique nozzle names).
- `scale ≤ 0` → `ValueError`. `layer` overrides the family layer.
- Defines the block on first use (`defined: true`), inserts with attributes,
writes XDATA, returns WCS ports (rotation and scale applied).
- Cost `mutate`. Aliases `acad=("INSERT",)`, synonyms "valve", "pump", "tank",
"vessel", "instrument bubble", "p&id symbol", "control valve"...
### 6.3 `pid_line_draw(...)`
```python
pid_line_draw(from_: dict, to: dict, line_class: str = "process_major",
route: str | list = "auto", stub: float = 5.0,
line_number: str | None = None, size: str | None = None, service: str | None = None,
spec: str | None = None, insulation: str | None = None, number_format: str | None = None,
label: bool = True, arrow: bool | None = None)
→ {"handle", "vertices": [[x, y], ...], "line_class", "layer", "line_number",
"from": {"handle","port","x","y"} | {"x","y"}, "to": ..., "port_reuse": [...],
"crossings": int, "label_handle", "arrow_handle", "marker_handles": [...], "backend"}
```
- `from_`/`to`: `{"handle": H, "port": name}` or `{"x": .., "y": ..}` (a free
point, e.g. a junction on an existing line). A bare point gets no direction;
routing treats it as radial.
- Line classes (ISA-5.1 Table 5.3.1) → layer / linetype override / marker:
| class | layer | linetype | marker | arrow default |
|---|---|---|---|---|
| `process_major` | PROCESS-PIPING-MAIN | ByLayer (Continuous) | — | yes |
| `process_minor` | PROCESS-PIPING-SECONDARY | ByLayer | — | yes |
| `utility` | UTILITY-LINE | ByLayer (PHANTOM) | — | yes |
| `pneumatic` | INSTRUMENT-LINE-SIGNAL | Continuous (override) | `mark_pneumatic` | no |
| `electric` | INSTRUMENT-LINE-SIGNAL | ByLayer (DASHED) | — | no |
| `hydraulic` | INSTRUMENT-LINE-SIGNAL | Continuous | `mark_hydraulic` | no |
| `capillary` | INSTRUMENT-LINE-SIGNAL | Continuous | `mark_capillary` | no |
| `data` | INSTRUMENT-LINE-SIGNAL | Continuous | `mark_data` | no |
Markers are blocks (section 4.4) placed at the midpoint of every segment
≥ 15 mm, rotated with the segment; they are decoration, ignored by the
reader. Rationale: SHX-shape complex linetypes are not portable across the
two engines' renderers; blocks are.
- Line number: given verbatim, or built from `number_format` (default
`"{size}-{service}-{seq}-{spec}-{insulation}"`, empty fields and their
separators dropped, `seq` auto-incremented per document from XDATA of
existing lines). Label: TEXT height 2.5 on `PROCESS-LINE-TEXT`, 1.5 mm off
the longest segment's midpoint on its left-hand side, rotated with the
segment and never upside-down (angles in (90°, 270°] are flipped by 180°).
- Arrow: `arrow_flow` block at the end point, rotated with the last segment;
default per class, parameter overrides.
- `crossings`: intersections between the new segments and existing P&ID lines
(lines on P&ID line layers or carrying line XDATA), excluding touches at a
shared endpoint. Reported, never refused.
- `port_reuse`: ports that already had a line attached (branching is legal;
the fact is reported).
- Refusals (`ValueError`): same handle+port at both ends; unknown port (the
message lists the symbol's ports); a marker/arrow block as an endpoint; any
segment shorter than 0.01 mm; `route` waypoints that repeat a point; `stub`
< 0.
- One LWPOLYLINE per call. XDATA as in 5.2. Cost `mutate`. Aliases
`acad=("PLINE",)` plus synonyms "process line", "pipe run", "signal line",
"connect equipment", "instrument line".
### 6.4 `pid_tag_parse(tag: str, kind: str = "auto", equipment_prefixes: dict | None = None) -> dict`
Section 8. Cost `read`. Also used internally by insert and critique.
### 6.5 `pid_graph(...)`
```python
pid_graph(tolerance: float = 0.5, label_search: float = 15.0,
scope: str = "current_space", # or "all"
include_foreign: bool = True, include_geometry: bool = False)
→ {"nodes": [...], "edges": [...], "junctions": [...], "dangling": [...],
"unclassified": [...], "offpage_links": [...],
"stats": {"nodes_by_kind": {}, "edges_by_class": {}, "dangling": n,
"unclassified": n, "confidence_min": float, "entities_scanned": n},
"tolerance", "scope", "backend"}
```
Section 9. Cost `read`. Aliases synonyms "p&id connectivity", "instrument
index", "what is connected to", "trace the line".
### 6.6 `pid_instrument_index / pid_line_list / pid_equipment_list(csv_path: str | None = None, **graph_kwargs)`
Section 9.5. Each returns `{"rows": [...], "count", "csv_path": str | None,
"graph_stats": {...}}`. `csv_path` goes through `validate_path(path,
allow_write=True)`; the CSV header is the row keys in the documented order.
Cost `read` — the drawing is never mutated; the optional CSV write is named in
the tool summary so a client gating on cost is not surprised by a file.
### 6.7 `pid_from_spec(spec: dict, dry_run: bool = False) -> dict`
Section 10. Cost `mutate`.
## 7. Routing (`engineering/pid/lines.py`)
Inputs: start point `S`, start direction `dS` (unit axis vector or `None`),
end point `E`, end direction `dE`, `stub`. Radial ports (bubbles, free points)
take their direction from the vector towards the other end, snapped to the
nearest axis, and their start point on the circle at `centre + r·dir`.
`route="auto"` enumerates candidates and picks the first that is valid, in this
order; ties inside a candidate class are broken by total length:
1. **straight** — `S→E` when both lie on one axis line and each end's direction
points towards the other (or is `None`).
2. **L** — one bend at `(E.x, S.y)` or `(S.x, E.y)`; valid when the first
segment leaves `S` along `dS` and the last arrives at `E` against `dE`.
3. **Z** — two bends through the midline between the stub points, along
`dS`'s axis; then along `dE`'s axis.
4. **U** — `S→S₁→(corner)→E₁→E` with both stubs honoured when the ends face
each other or away (three bends).
Validity for every candidate: the first segment is colinear with `dS` and
≥ `stub` long, the last is colinear with `−dE` and ≥ `stub` long, no segment is
shorter than `stub` except the whole route when `straight`, and consecutive
segments are perpendicular. If no candidate is valid (e.g. `stub` larger than
the gap), the tool raises `ValueError` naming the gap and the stub, so the
caller can shorten the stub or pass waypoints. `route="direct"` draws `S→E`.
`route=[...]` draws `S→w₁→…→E` verbatim after the refusal checks.
Everything here is pure geometry in `lines.py`, tested without a backend.
## 8. ISA-5.1 tag grammar (`engineering/pid/tags.py`)
Format: `^(?:(?P<area>\d{1,4})-)?(?P<letters>[A-Z]{1,5})[- ]?(?P<loop>\d{1,5})(?:[- ]?(?P<suffix>[A-Z]))?$`
after upper-casing and trimming. Letters are then parsed with the grammar:
```
tag := first modifier? readout* output* trailing?
first := A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z
modifier := D | F | K | Q | S # differential, ratio, rate of change, integrate, safety
readout := A | B | E | G | I | L | N | O | P | R | U | W | X
output := C | K | S | T | U | V | X | Y | Z
trailing := HH | LL | H | L | M | O | C # high-high, low-low, high, low, middle, open, closed
```
Disambiguation rules (fixed, tested):
- `S` in second position is the **safety** modifier only when followed by `V`
or `E` (`PSV`, `TSE`); otherwise it is the **switch** output (`PSL`, `LSHH`).
- `D` in second position is always **differential** (`PDT`, `PDIC`).
- `F` in second position is **ratio** only when the first letter is also `F`
(`FFC`); otherwise it is parsed as a readout error.
- `K` in second position is **rate of change** only when followed by an output
letter; `K` elsewhere is the control-station output (`HIK`).
- `Q` in second position is **integrate** (`FQI`, `FQ`).
- `H`, `L` and `M` are **trailing** modifiers (high/low/middle) when they follow
`A` (alarm), `S` (switch) or another trailing letter — `LAL` is *Level Alarm
Low*, `PSL` is *Pressure Switch Low*. `L` anywhere else is the readout
*light* (`PL`).
- `O` and `C` are **trailing** (open/closed) only as the last letter directly
after `S` (`ZSO`, `ZSC`); otherwise `C` is the *control* output (`TIC`) and
`O` the readout *orifice/restriction* (`FO`).
- A first letter in `{C, D, G, M, N, O}` is *user's choice* and a readout/output
in `{B, N, X}` is *unclassified*: the parse succeeds with
`user_defined: true` and no description word for that letter.
- Anything the grammar rejects → `valid: false` with `errors` naming the first
offending letter and position; the tag is still echoed back.
Output:
```json
{"tag": "10-FIC-101A", "valid": true, "kind": "instrument", "area": "10", "letters": "FIC",
"first": {"letter": "F", "meaning": "Flow rate"}, "modifier": null,
"readout": [{"letter": "I", "meaning": "Indicate"}], "output": [{"letter": "C", "meaning": "Control"}],
"trailing": null, "loop": "101", "suffix": "A", "description": "Flow Indicating Controller",
"user_defined": false, "errors": []}
```
Equipment tags: `^(?:(?P<area>\d{1,4})-)?(?P<prefix>[A-Z]{1,3})-(?P<number>\d{1,5})(?P<suffix>[A-Z]{0,2})$`
with the default prefix map `{P: pump, V: vessel, T: tank, E: exchanger,
K: compressor, C: column, R: reactor, F: filter, D: drum, M: mixer, H: heater,
S: separator, B: blower, X: package}` — ISO 10628 does not standardise these,
so `equipment_prefixes` replaces the map per call and `kind="equipment"` forces
the equipment parse. `kind="auto"` tries instrument first, then equipment.
Fixture: `tests/data/isa51_tags.json` with ≥ 40 valid tags and their expected
descriptions and ≥ 10 invalid ones with the expected first error — the file a
reader checks against ISA-5.1 Table 4.1.
## 9. Graph model and algorithm (`engineering/pid/graph.py`)
### 9.1 Model
```
Node {id: handle, kind: instrument|equipment|valve|connector|unknown_block,
block_name, family, symbol, tag, tag_parsed, tag_source: attribute|xdata|text|null,
x, y, rotation_deg, scale, layer,
ports: {name: {x, y, direction_deg, kind, inferred: bool, edges: [edge ids]}},
source: catalog|xdata|heuristic|inferred, confidence: 1.0|0.95|0.6|0.3}
Edge {id: handle, line_class, class_source: xdata|layer|null, line_number, number_source: xdata|label|null,
size, service, spec, insulation, from: {node, port}|{junction}|null, to: ..., length,
vertices (only with include_geometry), layer, source: xdata|layer|touch}
Junction {id: "J<n>", x, y, edges: [...]} # a line endpoint on another line's vertex or segment
Dangling {edge, end: "from"|"to", x, y, nearest: {node, port, distance}|null}
Offpage {link, nodes: [...]} # connectors sharing a LINK value
```
### 9.2 Algorithm
1. **Collect** with `entity_list` paged (`limit=1000`, `offset` loop) over the
current space (or every layout when `scope="all"`, each entity tagged with
its space).
2. **Classify INSERTs**: block name in the catalogue (`PID_*`, markers and
arrows excluded) → `catalog` 1.0; else XDATA `ACADMCP_PID` with
`kind="symbol"` → `xdata` 0.95; else heuristic 0.6 when
`include_foreign` and either an attribute tag in `{TAG, TAGNO, TAGNUMBER,
EQUIP_TAG, EQUIPMENT_TAG, INST_TAG, INSTTAG}` exists or the block name
contains one of `VALVE, VLV, PUMP, TANK, VESSEL, DRUM, INST, BUBBLE, HX,
EXCH, FILTER, COMP` (kind from the keyword); else the INSERT is kept only if
step 5 finds a line touching its bounding box → `unknown_block` 0.3,
otherwise dropped (title blocks, logos, north arrows).
3. **Ports**: catalogue/XDATA ports transformed by the INSERT's rotation and
scale about its insertion point. Foreign/unknown blocks: ports are
**inferred** in step 5 — every distinct line endpoint within `tolerance` of
the block's bounding-box boundary becomes `p1..pn` with `inferred: true`.
4. **Candidate edges**: LINE and LWPOLYLINE (and POLYLINE) entities that (a)
carry line XDATA, or (b) lie on a P&ID line layer (`PROCESS-PIPING-MAIN`,
`-SECONDARY`, `UTILITY-LINE`, `INSTRUMENT-LINE-SIGNAL`, `ELECTRICAL-LINE`),
or (c) when `include_foreign`, touch a node's port or bounding box at an
endpoint. `line_class` from XDATA, else from the layer:
`PROCESS-PIPING-MAIN → process_major`, `PROCESS-PIPING-SECONDARY →
process_minor`, `UTILITY-LINE → utility`, `ELECTRICAL-LINE → electric`,
`INSTRUMENT-LINE-SIGNAL → electric` when the effective linetype is dashed,
otherwise `signal_unknown` (`pneumatic` is never assumed from a plain line);
any other layer → `null`.
5. **Resolve endpoints** through a uniform grid (cell = 10·tolerance): a port
within `tolerance` → `{node, port}`; else another edge's vertex within
`tolerance` → junction; else a point within `tolerance` of another edge's
segment interior → junction (the touched edge is not split); else
`dangling` with the nearest port within `10·tolerance` as a hint.
6. **Tags**: instrument `FUNC`+`LOOP` → `TAG` attribute → the TEXT/MTEXT whose
insertion lies inside the bubble radius → null. Equipment/valve: `TAG`
attribute → nearest TEXT within `label_search` above the bounding box →
null. Line numbers: XDATA → nearest TEXT within `label_search` of the
longest segment whose content matches `^\d+(?:"|mm)?-[A-Z]{1,4}-\d+` →
null. Every source is reported.
7. **Off-page links**: connectors with equal `LINK` values (same file) are
joined in `offpage_links`; the reader does not invent an edge between them.
8. **Stats** as in 6.5; `confidence_min` over nodes is the number a caller must
read before trusting a foreign graph.
Complexity O((n + m)·k) with the grid; performance is measured in the perf
lane on a 5 000-entity synthetic P&ID, not asserted in unit tests.
### 9.3 Foreign drawings
Headless: `drawing_open(dxf)` then `pid_graph`. Live: `drawing_open(dwg)`.
CTO-library drawings classify through their `TAG`-style attributes and
keyword block names at confidence 0.6 with inferred ports; a test fixture
(hand-authored blocks `GATE_VALVE`, `CENTRIFUGAL_PUMP` with a `TAG` attribute,
plain lines) pins that path.
### 9.4 Reader rules
- The reader never modifies the drawing.
- It reads geometry from the drawing, never from the catalogue: a scaled or
rotated INSERT is measured, not assumed.
- Confidence is never raised by agreement between heuristics; it is the
minimum of its inputs.
### 9.5 Deliverables (`engineering/pid/deliverables.py`)
| Tool | Row keys, in order |
|---|---|
| `pid_instrument_index` | `tag, func, loop, description, type, location, connected_to (comma-joined equipment/valve tags or line numbers), signal_lines (count), handle, confidence` |
| `pid_line_list` | `line_number, line_class, size, service, spec, insulation, from_tag, from_port, to_tag, to_port, length_mm (rounded 1), handle, number_source` |
| `pid_equipment_list` | `tag, kind, symbol, description, ports (count), connected_lines (count), unconnected_ports (comma-joined), handle, confidence` |
Rows are sorted by tag (natural sort: `P-2` before `P-10`), untagged rows last
with `tag: null`.
## 10. `pid_from_spec`
Schema (validated with the same strictness as `block_define`; every error names
the path, e.g. `lines[2].from`):
```json
{"sheet": {"layer_set": "pid", "sheet_size": "A3", "scale": 1.0, "intent": "Feed section"},
"equipment": [{"id": "P-101", "symbol": "centrifugal_pump", "x": 100, "y": 100, "rotation": 0,
"tag": "P-101", "desc": "Feed pump"},
{"id": "V-201", "symbol": "vertical_vessel", "x": 220, "y": 120,
"params": {"width": 20, "height": 40, "nozzles": [{"name": "N1", "side": "top", "fraction": 0.5},
{"name": "N2", "side": "left", "fraction": 0.3}]}}],
"valves": [{"id": "HV-101", "symbol": "gate", "actuator": "hand", "x": 160, "y": 104, "tag": "HV-101"}],
"instruments": [{"id": "FIC-101", "type": "dcs", "location": "primary", "x": 160, "y": 140, "tag": "FIC-101"}],
"lines": [{"from": "P-101.discharge", "to": "HV-101.in", "class": "process_major", "number": "100-P-1001-CS1"},
{"from": "HV-101.out", "to": "V-201.N2", "class": "process_major", "number": "100-P-1001-CS1"},
{"from": "FIC-101", "to": "HV-101.signal", "class": "electric"}],
"connectors": [{"id": "OP-1", "direction": "out", "x": 300, "y": 120, "tag": "TO P&ID-002", "link": "L-17",
"from": "V-201.N1", "class": "process_major"}]}
```
Behaviour: `drawing_plan` is called with the sheet block when no plan is held
(so critique replays it); then, inside `transaction_begin`, symbols are
inserted in the order equipment → valves → instruments → connectors, then lines
(`id.port` references resolve to handles; an instrument reference without a
port means its radial `signal` port). A connector with `direction: "out"`
carries `from` (the line runs into the connector); one with `direction: "in"`
carries `to` (the line runs out of it); each becomes one `pid_line_draw` call
with the connector's single port. On any error the transaction is rolled
back and the error names the failing item; on success the transaction is
committed and the result is `{"handles": {id: handle}, "graph": pid_graph(),
"critique": [pid issues], "crossings_total"}`. `dry_run=True` validates the
spec and resolves the routing in memory, returning the planned vertices and
crossings without touching the drawing.
## 11. Critique, refine, plan, finalize, prompt
### 11.1 Focuses (added to `CritiqueFocus` and `ALL_CRITIQUE_FOCUSES`)
| focus | severity | condition | handles / detail |
|---|---|---|---|
| `pid_dangling_line` | error | an edge end resolves to nothing | edge; `end`, `x, y`, `nearest` |
| `pid_duplicate_tag` | error | two nodes carry the same full tag (instrument `FUNC-LOOP` including suffix; equipment tag) | both nodes; `tag` |
| `pid_incompatible_connection` | error | a `signal`-class edge ends on a `process` port, or a process-class edge ends on a `signal` port | edge + node; `expected`, `actual` |
| `pid_untagged_instrument` | warning | an instrument node with empty `FUNC` or `LOOP` | node |
| `pid_illegal_tag` | warning | `pid_tag_parse` returns `valid: false` for a node's tag | node; `errors` |
| `pid_unconnected_equipment` | info | an equipment/valve node with zero edges on every port | node |
All six build one `pid_graph(include_foreign=True)` per critique run (cached
within the run) and return `[]` when `stats["nodes_by_kind"]` is empty, so a
drawing without P&ID content is never scored on these rules. They run under
`focus=None` and therefore inside `drawing_finalize`.
### 11.2 Refine hints (`engineering/refiner.py` registry)
No deterministic repair moves geometry. `pid_dangling_line` → hint naming the
nearest port and the `pid_line_draw` call that would connect it;
`pid_duplicate_tag` / `pid_illegal_tag` / `pid_untagged_instrument` → hint
naming the node and `block_set_attributes`; `pid_incompatible_connection` →
hint to redraw with the right class; `pid_unconnected_equipment` → informational.
### 11.3 Plan
`detect_ambiguities` gains one rule: intent text matches
`\b(p&id|pid|piping and instrumentation|process flow|instrument)\b` (case-
insensitive) while `layer_set_id != "pid"` → clarification "intent reads as a
P&ID but the layer set is `<id>`; pass `layer_set_id='pid'`".
### 11.4 Prompt
`prompt_pid_diagram(project_name, revision)` is rewritten to the tool
workflow: `drawing_plan(layer_set_id="pid")` → `drawing_apply_iso_layers("pid")`
→ `pid_symbol_insert` per equipment/valve/instrument (tags in ISA-5.1 form) →
`pid_line_draw` port-to-port (never free points for equipment connections) →
`pid_graph` and read `dangling`/`confidence_min` → `drawing_critique(focus=None)`
must be empty → `drawing_finalize`. It states the reference sizes (section 4.2)
and that `pid_from_spec` does the whole sheet in one call.
### 11.5 Validator
No change: none of the eight steps assumes mechanical content. A test runs
`drawing_finalize` on the spec example and asserts the score is computed and
≥ 90 with zero P&ID issues.
## 12. Tool packs (`TOOL_PACKS`)
- `config.settings.tool_packs: str = "all"`; parsed as a comma list, trimmed,
lower-cased. Valid packs in 1.6.0 after this track: `core`, `pid` (later
tracks add `mech`, `arch`, `plant3d`, `settings`). `core` is every registered
tool not claimed by another pack and cannot be disabled; an unknown pack name
logs a warning, is reported in `system_about["tool_packs"]["ignored"]`, and
is otherwise ignored (the server boots).
- `PACK_TOOL_NAMES: dict[str, frozenset[str]]` next to `LEAN_TOOL_NAMES`;
`_profile_enabled_names` becomes `enabled = registered ∩ packs_enabled`,
then `∩ LEAN_TOOL_NAMES` for `lean`, then the `ENABLE_3D` subtraction.
- `LEAN_TOOL_NAMES` gains `pid_symbol_insert`, `pid_line_draw`, `pid_graph`
(lean = 50).
- `system_about` reports `tool_packs: {"enabled": [...], "available": [...],
"ignored": [...]}`; `_active_tool_profile` records the same. The README token
table gains a row per pack (measured with `benchmarks/token_suite.py`).
- The `unknown_tool` rejection message names `TOOL_PACKS` alongside
`TOOL_PROFILE` / `ENABLE_3D`.
## 13. Resources
- `autocad://pid/symbols` — the `pid_symbol_list()` payload.
- `autocad://standards/isa51` — the letter tables and grammar summary from
`tags.py`, so a model can read the standard instead of guessing.
Both are static, cost nothing in the tool budget, and are added to
`docs/tool-inventory.json` (resources section) by the generator.
## 14. Error handling summary
- Validation errors are `ValueError`/`TypeError` raised **before** any write,
with the offending key or index in the message; the server wrapper turns them
into `ToolError` as it does today.
- Engine boundaries: none in this track — every method is abstract on both
engines. (`drawing_open` of a DWG headlessly stays the existing refusal.)
- Reader results carry `source`/`confidence` fields instead of raising on
uncertainty; only a failure to read the drawing at all raises.
- Nothing in `pid_*` calls `system_run_command` / `system_run_lisp`.
## 15. Testing and evidence
Unit (headless, CI):
- `tests/test_pid_symbols.py` — for **every** catalogue entry and every
generated variant: primitives pass `block_define` validation, declared
`bbox` contains every primitive, every port lies on the bbox boundary (or at
the centre for radial ports), ≥ 1 port, ATTDEF tags unique, name matches
the pattern, `source` non-empty; parametric hash stability; actuator
composition adds exactly one `signal` port.
- `tests/test_block_define.py` — ezdxf: definition, ATTDEF count, insert with
attributes, overwrite semantics (existing INSERT shows new geometry),
refusals (empty, bad spec index, name clash, anonymous name); COM: the
fake-ActiveX pattern from `test_block_from_entities.py` asserting the
`Blocks.Add` / `AddAttribute` call sequence and the invisible mode flag.
- `tests/test_xdata.py` — both engines: typing by group code, round trip,
removal on empty list, 255-char and 16 KB refusals, APPID registration,
chunked JSON round trip through `pid/xdata.py`.
- `tests/test_pid_lines.py` — pure routing: straight/L/Z/U selection, stub
honoured, no-candidate refusal, waypoint validation, upside-down label
angle correction, crossing count against an existing line, marker placement
on segments ≥ 15 mm only, class → layer/linetype table, line number format
with empty fields, `seq` auto-increment.
- `tests/test_pid_tags.py` — the JSON fixture (≥ 40 valid / ≥ 10 invalid) plus
the disambiguation rules one by one; equipment prefix override.
- `tests/test_pid_graph.py` — a P&ID drawn with the tools: expected node kinds,
edge count, zero dangling; nudge one symbol by 1 mm (> tolerance) →
`dangling` and `nearest` reported; a branch line onto a segment → junction;
scale 2 / rotation 90 INSERT → ports measured correctly; foreign fixture →
heuristic nodes at 0.6, inferred ports, `confidence_min < 1`; a title block
INSERT is not a node; `scope="all"` across two layouts; off-page link pairing.
- `tests/test_pid_deliverables.py` — row order and keys, natural sort, CSV
written under `ALLOWED_PATHS` and refused outside.
- `tests/test_pid_critique.py` — each focus positive and negative; all six
return `[]` on the README gear sheet; `drawing_refine` emits hints and moves
nothing.
- `tests/test_pid_from_spec.py` — the section 10 example end to end; a bad
reference rolls back (entity count unchanged); `dry_run` writes nothing;
`drawing_finalize` score ≥ 90.
- `tests/test_tool_packs.py` — `TOOL_PACKS=core` hides every `pid_*` tool and
keeps `block_define`; unknown pack ignored with `ignored`; `core` cannot be
disabled; interaction with `lean` and `ENABLE_3D`.
- Existing gates extended: alias records for 12 tools (`test_discovery_aliases`),
`docs/tool-inventory.json` regenerated (`test_tool_inventory`), README
snapshot line and `SECTION 17: P&ID (9 tools)` header
(`test_release_consistency`), capability maps unchanged
(`test_capability_contract`).
Render evidence: `scripts/render_pid_catalog.py` renders every base symbol and
the 20 bubble variants to `docs/assets/pid-catalog.png` (contact sheet, labelled)
and `scripts/render_readme_pid.py` renders the section 10 example to
`docs/assets/pid-showcase.png`; a test asserts each render is non-blank.
Live COM: `scripts/smoke_pid_com.py` runs `block_define` → `pid_symbol_insert`
→ `entity_set/get_xdata` → `pid_line_draw` → `pid_graph` in a **new** document
on the running AutoCAD (never the operator's open drawing), prints the graph
stats, and its output plus the AutoCAD version go into the CHANGELOG entry. If
no AutoCAD is reachable the script exits 2 and says so; the changelog then
states the COM paths are fake-tested only.
Benchmark: `benchmarks/tasks_v4.py = TASKS_V3 + ("pid_roundtrip", "pid",
"Draw a three-equipment P&ID and read it back with matching index and line
list", 1.0)`; `run_competitors --matrix v4`; competitor reports stay unfilled
(`not run`). Correctness suite +3: `pid_block_define_attdef_roundtrip`,
`pid_tag_parse_fic`, `pid_graph_edge_count` — all `miss → pass` against
v1.5.1.
## 16. Documentation and gates to update
- `CLAUDE.md`: section "17. P&ID (9 tools)", the two generic contracts, the
`TOOL_PACKS` variable, the reader rules (section 9.4) in Key Conventions.
- `README.md`: "What you get" row for P&ID, the catalogue contact sheet, the
token table row per pack, `Configuration` entry for `TOOL_PACKS`, snapshot
line (166 tools, 8 resources).
- `CHANGELOG.md`: `Unreleased` → 1.6.0 entry for track A with the measured
refusals and the live COM smoke record.
- `.env.example`: `TOOL_PACKS`.
- `docs/tool-inventory.json`: regenerated.
## 17. Milestones (input to the implementation plan)
1. Contracts: `block_define` + XDATA on both engines, tests, aliases, inventory.
2. Catalogue: `symbols.py` + `geometry.py`, catalogue tests, contact-sheet script.
3. `pid_symbol_list/insert`, layers, XDATA payloads.
4. `lines.py` + `pid_line_draw`, markers, arrows, labels.
5. `tags.py` + `pid_tag_parse` + fixture + resource.
6. `graph.py` + `pid_graph` (+ foreign fixture), deliverables.
7. Critique focuses, refine hints, plan rule, prompt rewrite, finalize test.
8. `pid_from_spec`, README showcase render.
9. Tool packs, `system_about`, token table.
10. Benchmark v4 task, correctness checks, live COM smoke, docs, gates green.
@@ -0,0 +1,235 @@
# AutoCAD MCP Pro v1.6.0 — Roadmap and decomposition
**Date:** 2026-09-15 · **Status:** decomposition approved; every track below gets its
own design spec and implementation plan before code · **Baseline:** v1.5.1
(154 registered tools, 19 groups, 1369 tests)
## 1. Goal
An LLM driving this server can — without guessing coordinates, to a named
standard, and with the result verified by the server itself —
- (A) draw a **P&ID** and read one back as a graph, instrument index and line list;
- (B) produce a complete **machine-part / assembly sheet** (views, sections,
standard parts, symbols, BOM, balloons);
- (C/H) **understand** an existing or foreign drawing (topology, standards,
revision diff);
- (D) query an **AutoCAD Plant 3D** project read-only;
- (E) control **AutoCAD's own settings** (styles, page setup, templates,
preferences, documents);
- (F) draft an **architectural** floor plan and read rooms back from one.
The bar is "best in every category, and evidenced" — not the longest checklist.
Features ship when their contracts and limitations are testable (README,
"Roadmap (1.6)").
### Success criteria (measured, not asserted)
| # | Criterion | Where it is measured |
|---|---|---|
| 1 | `pid_roundtrip`: a three-equipment P&ID drawn with the tools is read back by `pid_graph` with the expected edge count, zero dangling lines, a correct instrument index and line list; `drawing_critique` returns 0 issues; `drawing_finalize` score ≥ 90 | new benchmark task + correctness suite |
| 2 | A foreign P&ID (plain blocks + `TAG` attributes, no catalog) yields a graph whose every node carries a confidence < 1.0 — no fabricated certainty | unit test on a hand-authored fixture |
| 3 | `mech_assembly`: a flange-coupling / bearing-housing style sheet (views + section + BOM + balloons + ISO 129 dims) from generators; critique 0 issues; score ≥ 90 | new benchmark task |
| 4 | Topology DRC parity with beiming v4 (`DANGLING_ENDPOINT`, `NEAR_MISS`, `INTERIOR_CROSSING`) and `drawing_diff` on two revisions of the README hero sheet | tests + benchmark task |
| 5 | Plant 3D line list / BOM / weld list correct on a synthetic `.dcf` fixture built from the public projects' schema, and manually checked against the three public projects | tests + `scripts/verify_plant3d_public.py` |
| 6 | Correctness A/B vs v1.5.1: zero regressions (`python benchmarks/compare_versions.py v1.5.1`) | release gate |
| 7 | Headless creation throughput back to v1.4.0 numbers (timeout armed only where a call can block) | perf lane |
| 8 | Re-score on the 100-point rubric in `benchmarks/source_review.json` ≥ 90 **with evidence per category**, published next to the competitor rows | docs |
## 2. Competitive research (snapshot 2026-09-14)
Method: `gh search repos` / `gh api` over GitHub, 20 READMEs read in full
(Firecrawl was unavailable — HTTP 402). Stars as of the snapshot.
| Repository | ★ | What it has that v1.5.1 does not |
|---|---:|---|
| puran-water/autocad-mcp v3.1 | 506 | `pid` tool (`insert_valve/pump/tank/instrument`, `draw_process_line`, `connect_equipment`, `add_line_number`) — bound to the **commercial** CAD Tools Online symbol library at `C:\PIDv4-CTO\`; 8 consolidated tools; freehand AutoLISP |
| LokmenoWer/best-cad-mcp v1.6 | 16 | 322 tools (profiles 114/215/322). **Drawing understanding**: CAD-IR, semantic objects and graphs, dimension binding, constraint checks; CADPlan dry-run; BOM + balloons; image-to-CAD tracing; VLM grounding with pixel/world mapping. COM-only |
| beiming183-cloud/AutoCAD-MCP v4.0 + AutoCAD-skills | 7 + 7 | Native .NET transaction worker; topology DRC (`DANGLING_ENDPOINT`, `NEAR_MISS_CONNECTION`, `INTERIOR_CROSSING`), view-source consistency, plot-scale truth; product/industrial features; the skill expects threads, springs, bearings, splines |
| varavista/autocad-mcp v5.0 | — | `xref` (attach/detach/reload/bind), `validate` (connectivity, orphans, duplicates, qc_report), `export` (BOM, data_extract, Excel), `electrical` (NEC calcs, panel schedule, wire numbers), `titleblock_fill`, `batch_plot`, `page_setup`, natural-language commands |
| john0909/ZWCAD-Mechanical-MCP | 13 | 37 tools: `manage_bom` / `create_partlist`, `insert_balloon`, `manage_frame`, `manage_title_block(set_field)`, `query_dimensions`, `manage_style` (layer/linetype/textstyle/dimstyle), `manage_xdata`, `manage_dictionary`, `manage_document` |
| Romeraaa/autocadplant3d-mcp | 1 | Plant 3D project queried **read-only through its SQLite files** (`.dcf`, `.pspc`): line list, component list, BOM, pipe lengths, weld list, bolt/gasket list, `validate_specs`, `find_untagged` — no .NET plugin, no open session |
| AnCode666/multiCAD-mcp | — | Multi-CAD COM (AutoCAD/ZWCAD/GstarCAD/BricsCAD), multi-document switching, Excel export |
| thepiruthvirajan/autocad-mcp-server | 62 | Walls, doors, windows, building structures over COM |
| xstaar/autocad-mcp | 6 | YQArch architectural add-on, 684 commands |
| OmniZenRaj/dxf-mcp | 0 | Read-only DXF: BOM from block attributes, `check_standards` (required/forbidden layers, units) |
| mattmohandiss/cad-mcp-server | 5 | STEP inspection, DFM checks, `diff_step` revision compare (OCCT) |
| daobataotie/CAD-MCP | 547 | Basic COM drawing over three CAD brands; NL command parsing — stars, not capability |
| mixelpixx/KiCAD-MCP-Server | 2246 | Not AutoCAD; the most-starred CAD MCP, shows the ceiling of the category |
P&ID ecosystem outside MCP:
- **Coldbari/IPD-Studio** (25★) defines "intelligent P&ID": ISA-5.1 tag parsing
(`FIC` → Flow Indicating Controller), loop derivation, instrument index and
line list *generated from the model*, 16 line classes, off-page connectors,
live validation (duplicate/missing/illegal tags, dangling lines, incompatible
connections), DEXPI-shaped XML, R12 DXF export.
- **process-intelligence-research/pyDEXPI** (222★) — DEXPI 1.3 data model in
Pydantic, Proteus XML loader, NetworkX graph. **AGPL-3.0**: cannot be a
dependency of an MIT server. DEXPI export is therefore out of 1.6.
- **Public Plant 3D projects with real `.dcf` / `.pspc` files**:
`Aggie-Propulsion-Lab/Mu-Propellant-Feed` (Piping.dcf 397 KB, ProcessPower.dcf
1.0 MB, 30 spec sheets), `bnguyen07/AutoCAD` ("Project 1"),
`marianabucsa/University` (Lab10). None carries a licence, so they are
**schema sources only** — never committed; tests run on a synthetic fixture
generated by our own script with the same schema.
Today's P&ID support in v1.5.1 is a prompt template (`prompt_pid_diagram`) that
tells the model to hand-draw pumps as circle + triangle — the "no coordinate
guessing" rule, violated by our own prompt.
## 3. Tracks
Each track: scope · not in 1.6 · headline evidence. Detailed designs live in
their own specs (`2026-MM-DD-v1.6-<track>-design.md`).
### A · P&ID — expert and reader (`engineering/pid/`)
- Symbol catalogue authored **in code** (ISO 10628-2 equipment and valves,
ISA-5.1 instrumentation): ~41 base symbols, valve × actuator variants, 4 × 5
instrument-bubble variants, parametric vessels; every symbol a real block
with `TAG` attributes and named **ports** (position + direction).
- Two new generic contracts: `block_define(name, entities, attdefs)` and
`entity_get/set_xdata`.
- `pid_symbol_list/insert`, `pid_line_draw` (port-to-port orthogonal routing,
ISA-5.1 line classes → layer/linetype/marker, line numbers), `pid_tag_parse`
(ISA-5.1 grammar), `pid_graph`, `pid_instrument_index`, `pid_line_list`,
`pid_equipment_list`, `pid_from_spec`; critique focuses `pid_*`;
`prompt_pid_diagram` rewritten onto the tools.
- Not in 1.6: DEXPI export, ISA-5.2 logic symbols, obstacle-avoiding routing,
cross-file off-page links (same-file layouts only).
- Evidence: criteria 1 and 2.
### E · AutoCAD settings and environment
- `dimstyle_*`, `textstyle_*`, `mleaderstyle_*`, `tablestyle_*` (ISO-25 /
ISOCPEUR authored, both engines); `drawing_settings` extended (limits, grid,
snap, ortho, polar, LTSCALE/PSLTSCALE, CANNOSCALE, INSUNITS/LUNITS/AUNITS)
over a **validated whitelist** plus `system_variable_describe`.
- `page_setup_*` with CTB selection, `batch_plot`; `.dwt` templates
(`drawing_new(template=)`, bundled ISO A3-mech / A1-arch / A3-P&ID).
- COM-only, refused headlessly: `system_preferences_get/set`,
`drawing_properties` (DWGPROPS), `document_list/activate/close`,
`system_launch`. Both engines: `layer_state_*`, `view_named_*`, `ucs_*`.
- Evidence: settings round-trip tests both engines; a page-setup PDF whose
mediabox is read back (beiming's "plot-scale truth").
### B · Mechanical 2D — the complete part drawer (`engineering/mech/`)
- Standard parts as generators: ISO 4014/4017 bolts, ISO 4032 nuts, ISO 7089
washers, ISO 4762 cap screws, ISO 6410 thread representation, DIN 471/472
ring grooves, DIN 509 undercuts, DIN 332 centre holes, ISO 15 / ISO 8826
bearings, ISO 3601 O-ring grooves.
- Part generators from one parameter source: `shaft_draw` (steps, chamfers,
fillets, keyways, threads, grooves → front view + section + auto dims),
`plate_draw` (outline + holes/slots/pockets → front + side, hidden lines for
through features), `flange_draw`; `mech_part_from_spec` does the whole sheet
in one call.
- Annotation symbols: surface texture ISO 21920-1 / ISO 1302, welding ISO 2553,
centre marks and centrelines, section line A-A, detail view; material hatch
map (ISO 128-50).
- BOM / parts list ISO 7573 with balloons; `bom_extract` from block attributes;
`data_extract` → CSV/XLSX.
- Not in 1.6: a general 3D → 2D projection kernel (views come from generators,
not from arbitrary solids), splines DIN 5480, springs.
- Evidence: criterion 3.
### G · Sheet and delivery standard
- `sheet_frame_iso5457(size)` with zone letters, centring and trimming marks;
A4–A0 title blocks (ISO 7200); revision block + `revision_add` (with
revcloud); `xref_attach/bind/reload/detach`; `image_attach` (raster
underlay); headless real DWG through `odafc` when the ODA converter is
installed, refused otherwise.
- Evidence: frame geometry pinned against ISO 5457 tables; xref round-trip.
### F · Architectural 2D (`engineering/arch/`)
- `arch_grid`, `arch_wall` (thickness, justification, corner clean-up),
`arch_door` (opens the wall, leaf + swing), `arch_window`, `arch_stair`,
`arch_room_label` (area measured, not typed), `arch_north_arrow`,
`arch_section_mark`, `arch_schedule(doors|windows|rooms)`; furniture and
sanitary block catalogue over `block_define`; ISO 13567 layers (existing),
ISO 4157 numbering, ISO 7519 / ISO 128-23 line rules, annotative text at
1:50 / 1:100.
- Reader: `arch_room_detect` — rooms and an area schedule from a foreign plan,
over `boundary_trace`.
- Not in 1.6: AutoCAD Architecture / Revit objects, 3D walls, IFC.
- Evidence: a two-room plan drawn, read back, schedule matches.
### H · Understanding and QA
- Topology DRC as critique focuses: `dangling_endpoint`, `near_miss`,
`interior_crossing`, `overlap`, `zero_length`; `check_standards`
(required/forbidden layers, units, text heights, dim styles);
`query_dimensions`; `drawing_scan` (view clusters, title block, tables);
`drawing_diff(a, b)` by handle and geometry digest.
- Evidence: criterion 4.
### D · Plant 3D, read-only (`plant3d/`)
- SQLite reads of `.dcf` / `.pspc`: `plant3d_list_projects/detect_project`,
`line_list`, `component_list`, `bom`, `pipe_length`, `weld_list`,
`bolt_gasket_list`, `validate_specs`, `find_untagged`. Never writes.
- Evidence: criterion 5.
### Cross-cutting (inside the tracks above)
- **I · Speed** — per-call timeout armed only where a call can block; `*_from_spec`
one-call sheets; perf lane re-measured (criterion 7).
- **K · Human in the loop** — `user_pick_point`, `user_select` (COM `GetPoint` /
`GetSelection`), `system_prompt_message`; refused headlessly.
- **L · Knowledge resources** — MCP resources for authored standard tables
(`autocad://standards/iso286`, `isa51_letters`, `layers/{set}`, `pid/symbols`):
zero tool-budget cost.
## 4. Order and dependencies
**A → E → B → G → F → H → D.**
- A first: the loudest gap (three rivals ship `pid`, we ship a prompt), and it
builds the block/port/graph infrastructure F reuses.
- E second, small: B and F need ISO dimstyles, text styles and templates.
- B before F: the existing engineering package, critique and score are
mechanical-shaped — proven pattern, deepened.
- G before F: frames, title blocks and revision block serve every sheet.
- H after the drawing tracks so the DRC has three domains to run against.
- D last: isolated, read-only, no shared code.
Each track = its own spec → plan → implementation → CHANGELOG entry. 1.6.0 is
released once every track's criterion is green; pre-release tags (`1.6.0a1`…)
after each track are optional and decided at that point.
## 5. Cross-cutting decisions
1. **Tool packs.** `TOOL_PACKS` (comma list; default `all`) with packs `core`,
`mech`, `pid`, `arch`, `plant3d`, `settings`. A client advertises only the
packs it uses; `TOOL_PROFILE` filters within enabled packs; `DISCOVERY_MODE`
indexes every enabled pack; `system_about` reports packs; idle token cost is
published per pack. Rationale: ~300 tools would push `full` to ~80k idle
tokens — best-cad-mcp's most criticised trait.
2. **Dual-engine by default; `@capability` only for real engine boundaries**
(unchanged rule). Every COM path added in 1.6 is executed once against the
live AutoCAD on the author's machine and recorded in the changelog; "written
against the ActiveX reference, never executed" is no longer an acceptable
final state where AutoCAD is available.
3. **Every tool** has an alias-corpus entry, a docstring, tests on both engines
(fake-ActiveX for COM), and refusals that were measured rather than assumed.
4. **Authored standards data is pinned by provenance tests** (the `fits.py`
pattern): symbol geometry, ISA letter tables, ISO 5457 frames, thread
tables — each with a test a reader can check against the standard.
5. **Benchmarks.** Each track adds a task to the matrix; competitor reports are
never back-filled (chart shows `not run`). The correctness suite grows per
track and the A/B gate runs against v1.5.1.
6. **Domain packs must not degrade other domains**: every new critique focus
returns nothing on a drawing with no entities of its domain, so a mechanical
sheet is not scored on P&ID rules and vice versa.
7. **No licence contamination**: pyDEXPI (AGPL) and the CTO library
(commercial) are excluded; public Plant 3D projects are schema sources only.
## 6. Explicitly out of 1.6
Electrical schematics IEC 60617 (**1.7** — cheap once A's symbol/graph
infrastructure exists), DEXPI export, image-to-CAD tracing, a STEP/3D kernel,
a bundled VLM judge, BricsCAD/ZWCAD/GstarCAD verification, Plant 3D writes,
ISA-5.2 logic symbols, Revit/IFC.