diff --git a/docs/superpowers/specs/2026-09-15-v1.6-pid-design.md b/docs/superpowers/specs/2026-09-15-v1.6-pid-design.md new file mode 100644 index 0000000..795f3ce --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-v1.6-pid-design.md @@ -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__[_]`, 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\d{1,4})-)?(?P[A-Z]{1,5})[- ]?(?P\d{1,5})(?:[- ]?(?P[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\d{1,4})-)?(?P[A-Z]{1,3})-(?P\d{1,5})(?P[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", 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 ``; 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. diff --git a/docs/superpowers/specs/2026-09-15-v1.6-roadmap-design.md b/docs/superpowers/specs/2026-09-15-v1.6-roadmap-design.md new file mode 100644 index 0000000..716cddc --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-v1.6-roadmap-design.md @@ -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--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.