Files
autocad-mcp-pro/docs/superpowers/plans/2026-09-24-v1.6-understand-implementation.md
T
Umut CANandClaude Opus 5.5 4cd5603725 docs(plan): track H skeleton — 12 tasks in three groups, interfaces and review focus pinned
Header, global constraints (confidentiality, read-only by default, nothing
invented, inferred units), the five real-world inputs no happy-path test
exercises (AutoCAD text codes, Cyrillic look-alike tags, repeated tags,
double-drawn pipe, a layout whose INSUNITS lies), file structure, the
interface block, execution with the worktree fast-forward rule, and the merge
notes. Task sections are drafted next.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 09:39:33 +03:00

230 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Track H — understanding foreign drawings, takeoffs, diff, topology: implementation plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Read drawings somebody else made — a one-call report, a scale check
between a P&ID and its layout, pipe and cable takeoffs whose topology comes
from the P&ID and whose lengths come from the layout, a revision diff and
topology checks — as six tools on both engines.
**Architecture:** One bulk read per drawing into frozen `EntityRecord`s (ezdxf
directly; a live document through a `Document.Export` DXF snapshot that does
not rename it); pure analysis modules under `engineering/understand/`; a thin
async layer and the server tools. No new contract member.
**Tech Stack:** Python 3.11, FastMCP 3, ezdxf 1.4.x, pywin32 (COM), openpyxl
(new `office` extra), pytest + pytest-asyncio, ruff — all through
`uv run --frozen`.
**Spec:** `docs/superpowers/specs/2026-09-24-v1.6-understand-design.md`
## Global Constraints
- **Run everything through `uv run --frozen …`.** Never `uv sync`, with one
exception: Task 1 adds the `office` extra, runs `uv lock` and then
`uv sync --frozen --all-extras` exactly once. If `uv run --frozen` fails with
`os error 32` on `.venv/Scripts/autocad-mcp.exe`, use
`uv run --frozen --no-sync …` for the rest of the task and say so. A fresh
worktree venv lacks extras: run commands there as `uv run --frozen --all-extras …`.
- **Commit** with
`git -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" commit`,
message ending with these two lines verbatim:
`Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>` and
`Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd`.
- **Known red:** `tests/test_release_consistency.py::test_document_numbers_match_the_tree`
drifts on the README collected-tests figure — deselect exactly that test in a
worktree and say so. Task 11 retakes the figure on main.
- **Confidentiality (spec §14):** no client drawing, spreadsheet, file name,
client or site name, or takeoff quantity from the user's archive enters the
repository, a test, a fixture, a benchmark result or a commit message. Every
fixture is synthetic and built by `tests/fixtures/plant_pair.py` (Task 2).
- **Read-only by default:** every tool of this track leaves the drawing
untouched, except `drawing_diff(markup=True)`; the takeoffs write a workbook
file, never the drawing.
- **Nothing is invented:** a power value missing from the P&ID stays empty; a
diameter that cannot be read stays `unassigned`; a length from an unscaled
P&ID is never presented as a physical length; no cable-section table is
authored (sections only from a caller's `section_rules`).
- **Units:** analysis runs in drawing units; takeoff lengths are reported in
metres from the **inferred** unit (spec §5), and a disagreement with
`INSUNITS` is a warning in the result and on the method sheet.
- **Before committing:** `uv run --frozen ruff check <changed files>` and
`uv run --frozen ruff format <changed files>`.
- **Registration gates** (when you register a tool): regenerate
`docs/tool-inventory.json`, bump the README release-snapshot tool count and
`tests/test_tool_registry.py`'s frozen group sizes, add an alias record per
tool in `discovery/aliases.py`.
- **Deviations are data**: fix a plan test to the verified truth, keep the
spec's behaviour, record it; never weaken a test.
## Review Focus
The five real-world inputs the spec implies and no happy-path test exercises —
each seen in, or one keystroke away from, the field drawings of 2026-09-24.
Each line's test lives in the owning task.
1. **AutoCAD text codes in labels** — `%%c51` (AutoCAD's Ø), `\U+2205`,
`⌀51`, `ø51` and MTEXT format runs (`{\fArial|b0;Ø51}`, `\P`) must all parse
as the diameter `Ø51`. *Owner: Task 1 (`labels.parse_diameter`).*
2. **Cyrillic look-alike letters in tags** — a Russian-labelled P&ID types
`Т4100` or `М82` with Cyrillic Т/М; it must match the layout's Latin
`T4100` / `M82`. *Owner: Task 1 (`labels.parse_tag`).*
3. **The same tag more than once** (two plan copies, a legend, a second sheet)
— the scale check and the takeoffs must not pick one arbitrarily: they use
only tags that are unique within the chosen cluster and report the rest as
`ambiguous`. *Owner: Task 4 (`scale.match_tags`), reused by Task 6.*
4. **Double-drawn pipe** — two overlapping collinear segments on a pipe layer
(a line drawn twice, or a line over a polyline) must count once, or every
takeoff doubles silently. *Owner: Task 5 (`network.build_network`).*
5. **A layout whose `INSUNITS` lies** — inches declared over millimetre
geometry: the takeoff must measure in the inferred unit (no ×25.4) and say
so. *Owner: Task 6 (`takeoff.pipe_rows`).*
---
## File Structure
| File | Responsibility | Task | Group |
|---|---|---|---|
| `engineering/understand/{__init__,snapshot,vocab,labels}.py` | records, bulk read, live snapshot, vocabulary, label parsers | 1 | main |
| `config.py`, `pyproject.toml`, `uv.lock` | `MAX_DXF_BYTES` 512 MB; `office` extra | 1 | main |
| `tests/fixtures/plant_pair.py` | the synthetic P&ID + layout pair and its truth | 2 | main |
| `engineering/understand/{units,clusters,describe}.py` | `drawing_understand` | 3 | U |
| `engineering/understand/scale.py` | `drawing_scale_check` | 4 | U |
| `engineering/understand/network.py` | line networks, runs, services, diameters, attachment | 5 | N |
| `engineering/understand/{route,takeoff}.py` | Manhattan, rectilinear MST, pipe rows | 6 | N |
| `engineering/understand/report.py` | the takeoff workbook (XLSX + CSV) | 7 | N |
| `engineering/understand/takeoff.py` (cable) | cable rows | 8 | N |
| `engineering/understand/diff.py` | `drawing_diff` | 9 | D |
| `engineering/understand/{topology,critique}.py` | `drawing_topology_check`, `topo_*` focuses | 10 | D |
| `server.py` SECTION 26 (Understanding & QA, core) | U opens it; D appends | 3, 4, 9, 10 | U, D |
| `server.py` SECTION 27 (Plant takeoffs, pack `plant`) | N owns it | 7, 8 | N |
| packs / lean / aliases / golden / counts | integration | 11 | final |
| benchmarks, smoke, field-test script, docs | evidence | 12 | final |
## Interfaces (the contract between tasks)
```python
# engineering/understand/snapshot.py (Task 1)
Pt = tuple[float, float]
@dataclass(frozen=True)
class EntityRecord:
handle: str
type: str # DXF type: LINE, LWPOLYLINE, POLYLINE, ARC, CIRCLE, SPLINE, ELLIPSE,
# TEXT, MTEXT, INSERT, DIMENSION, LEADER, MULTILEADER, HATCH, …
layer: str
space: str # "Model" or the paper layout's name
points: tuple[Pt, ...] = () # LINE ends; polyline vertices; TEXT/MTEXT/INSERT insertion; leader vertices
bulges: tuple[float, ...] = () # per vertex, LWPOLYLINE/POLYLINE
closed: bool = False
radius: float | None = None # CIRCLE / ARC
angles: tuple[float, float] | None = None # ARC start/end, degrees CCW
text: str | None = None # plain text: TEXT, MTEXT (format codes stripped), MULTILEADER, DIMENSION override
height: float | None = None # text height
block: str | None = None # INSERT block name
attribs: tuple[tuple[str, str], ...] = () # INSERT attributes (tag, value)
bbox: tuple[float, float, float, float] | None = None # WCS box (minx, miny, maxx, maxy)
rotation: float = 0.0
scale: tuple[float, float] = (1.0, 1.0)
@dataclass(frozen=True)
class Snapshot:
source: str # file path, or "live:<document name>"
insunits: int
extmin: Pt | None
extmax: Pt | None
layers: dict[str, dict] # name -> {"color": int, "linetype": str}
layouts: tuple[str, ...]
records: tuple[EntityRecord, ...]
def records_from_doc(doc, *, source: str) -> Snapshot # an ezdxf Document
def read_snapshot(path: str) -> Snapshot # refuses > config.settings.max_dxf_bytes, naming MAX_DXF_BYTES
async def take_snapshot(backend, path: str | None = None) -> Snapshot
# path given -> read that file (DXF; a DWG is opened read-only on COM and exported);
# None -> the current document: ezdxf reads its in-memory doc, COM uses
# Document.Export(<tmp>/snap, "DXF", <selection set of all>) and deletes the file afterwards.
def length_of(rec: EntityRecord) -> float # LINE, polylines with bulges, ARC, CIRCLE; 0 for others
# engineering/understand/vocab.py (Task 1)
SERVICES: tuple[str, ...] # product, cip_supply, cip_return, glycol_supply, glycol_return, steam, condensate,
# cold_water, hot_water, ice_water, compressed_air, drain, electrical, unknown
DISCIPLINES: tuple[str, ...] # piping, electrical, architecture, equipment, annotation, structure, unknown
def classify_layer(name: str) -> dict # {"discipline", "service", "confidence", "keyword", "language"}
def supply_return(text: str) -> str | None # "supply" | "return" | None — EN/TR/RU/NL/DE words
def room_label(text: str) -> dict | None # {"number": str | None, "name": str | None, "language": str}
# engineering/understand/labels.py (Task 1)
def plain(text: str) -> str # strips MTEXT codes, decodes %%c/%%d/%%p, \U+XXXX
def parse_diameters(text: str) -> tuple[str, ...] # tokens as drawn, normalised spelling: "Ø51", "Ø25,4", "SMS51", "DN20", "20x27", '1,5"'
def parse_tag(text: str) -> str | None # "T4100", "M82", "M41A", "M47.1", "CP-1", "CP-M82", "JB-2"; Cyrillic look-alikes folded
def normalize_panel(text: str) -> str | None # "CP 1" / "CP1" / "cp-1" -> "CP-1"; "CP M82" -> "CP-M82"
def parse_electrical(text: str) -> dict # {"kw": float | None, "phases": int | None, "voltage": int | None,
# "neutral": bool, "vfd": bool}
def wiring_target(text: str) -> str | None # "Wiring to CP1" / "подключение к шкафу CP-1" -> "CP-1"
# tests/fixtures/plant_pair.py (Task 2)
def build_plant_pair(directory: str | Path) -> dict # writes pid.dxf + layout.dxf, returns the truth dict:
# {"pid": str, "layout": str, "scale_verdict": "schematic", "layout_insunits": 1, "layout_true_unit": "mm",
# "outlier_handle": str, "cluster_count": 2, "layer_services": {layer: service},
# "pipe_runs": [{"service", "diameter", "tags": [..], "layout_length_mm": float, "status": "A"|"B"|"C"}],
# "cable_rows": [{"tag", "panel", "kw": float | None, "length_mm": float, "roundup_m": int}],
# "rooms": [{"number", "name", "polygon": [[x, y], ...]}]}
# engineering/understand/describe.py (Task 3)
def describe(snap: Snapshot) -> dict
# {"units": {...}, "extents": {...}, "clusters": [...], "layers": [...], "equipment_tags": [...],
# "rooms": [...], "languages": {...}, "blocks": {...}, "layouts": [...], "title_block": {...}, "warnings": [...]}
# engineering/understand/scale.py (Task 4)
def match_tags(a: Snapshot, b: Snapshot, *, cluster_a=None, cluster_b=None) -> dict # {"matched": {tag: (pa, pb)}, "ambiguous": [...]}
def scale_check(a: Snapshot, b: Snapshot, *, min_pair_mm=2000.0) -> dict
# {"verdict": "to_scale"|"schematic"|"partly"|"insufficient", "factor", "median", "iqr", "within_10pct",
# "pairs", "matched", "ambiguous", "groups": [...]}
# engineering/understand/network.py (Task 5)
@dataclass(frozen=True)
class Run: id: str; edges: tuple[dict, ...]; service: str; ends: tuple[Pt, ...]; tags: tuple[str, ...]
def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) -> dict # {"runs": [Run], "unassigned": [...], "stats": {...}}
# engineering/understand/route.py (Task 6)
def manhattan(a: Pt, b: Pt) -> float
def rmst(points: Sequence[Pt]) -> tuple[float, tuple[tuple[int, int], ...]] # rectilinear MST length and edges
# engineering/understand/takeoff.py (Tasks 6, 8)
def pipe_rows(network, pid: Snapshot, layout: Snapshot | None, *, scale=None, services=DEFAULT_SERVICES,
allowance=0.20, vertical_allowance=0.0, length_source="auto", force=False) -> dict
def cable_rows(pid: Snapshot, layout: Snapshot | None, *, allowance=0.20, section_rules=None) -> dict
# engineering/understand/report.py (Task 7)
def write_workbook(result: dict, *, kind: str, lang: str, path: str) -> dict # {"xlsx": path | None, "csv": [paths], "refused": {...} | None}
# engineering/understand/diff.py (Task 9)
def diff_snapshots(old: Snapshot, new: Snapshot, *, tol=0.01, move_tol=None) -> dict
# engineering/understand/topology.py (Task 10)
def topology_findings(snap: Snapshot, *, layers, gap=None, tol=0.01) -> dict # {"dangling": [...], "near_miss": [...], "crossing": [...]}
```
## Execution
| Group | Branch | Tasks | Owns |
|---|---|---|---|
| **(main)** | `main` | 1, 2 | `snapshot`, `vocab`, `labels`, config, extra, the fixture |
| **U** | `track-h/understand` | 3, 4 | `units`, `clusters`, `describe`, `scale`; SECTION 26 banner + its first two tools |
| **N** | `track-h/network` | 5, 6, 7, 8 | `network`, `route`, `takeoff`, `report`; SECTION 27 |
| **D** | `track-h/diff` | 9, 10 | `diff`, `topology`, `critique`; SECTION 26's other two tools |
| **final** | `main` (after merge) | 11, 12 | integration, evidence |
Tasks 1 and 2 land on `main` first; each group branch is then cut **from the
current `main`** (a worktree may open at an older commit: after `git checkout
-b <branch>`, run `git merge --ff-only main` and confirm `git log -1` is main's
HEAD before anything else).
**SECTION 26 banner protocol:** U opens `# SECTION 26: Understanding & QA
(1 tool)` in Task 3 and grows it to 2 in Task 4; D, on its own branch, opens
the same banner with its own count (1, then 2). The merge keeps one banner at 4
with U's block, then D's.
### Merge notes
Order **U → N → D**. Anticipated conflicts: `server.py` SECTION 26 (one banner,
blocks U then D) and the RESOURCES seam; `engineering/understand/__init__.py`
(additive re-exports); `discovery/aliases.py` (additive); `docs/tool-inventory.json`
(regenerate, never hand-merge); README release-snapshot tool count (from the
inventory; the collected-tests figure is Task 11's); `tests/test_tool_registry.py`
group sizes (from the live registry); `engineering/critique.py` focus
registration (D only). Anything else: `git merge --abort`, record, continue.
---