docs: track H (understanding foreign drawings) design spec
Read a drawing somebody else made and turn it into a deliverable: a one-call drawing report (units that lie, stray extents, plan copies, multilingual layer meaning), 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 by geometric signature, and topology checks. Grounded in a real client project read locally (never committed): its P&ID proved to be unscaled against its layout, which no tool had flagged. Six tools (+ pack plant), three topo_* focuses, the office extra, a live DXF snapshot through Document.Export (measured: the document is not renamed), and synthetic fixtures that reproduce the field findings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0abbe55041
commit
18995bf854
@@ -0,0 +1,305 @@
|
||||
# v1.6 Track H — understanding foreign drawings, takeoffs, diff and topology
|
||||
|
||||
**Date:** 2026-09-24 · **Status:** design approved in conversation ·
|
||||
**Parent:** `2026-09-15-v1.6-roadmap-design.md` (track H; track D stays separate
|
||||
and follows) · **Baseline:** main after track F (`0abbe55`, 241 tools)
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Every track so far taught the server to *draw*. This one teaches it to *read* a
|
||||
drawing somebody else made and turn it into a deliverable — the job a plant
|
||||
engineer does by hand when a client sends a P&ID and a layout and asks how much
|
||||
pipe and cable the job needs.
|
||||
|
||||
The design is grounded in a real client project from the user's archive, read
|
||||
on 2026-09-24 (the files are confidential and never enter this repository; see
|
||||
§14): a process plant with a 35,590-entity P&ID (96 layers, labels in
|
||||
Russian, layer names in English and Dutch, 296 block names, most anonymous),
|
||||
a 22,399-entity layout, a hand-made pipe takeoff and a hand-made cable
|
||||
takeoff. Four findings shape everything below:
|
||||
|
||||
1. **Today's reader cannot read it.** `pid_graph` found 441 nodes on that P&ID —
|
||||
427 of them `unknown_block` at confidence 0.3 — and 663 edges, every one of
|
||||
line class `unknown`, in 73 s. Its confidences were honest; its answer was
|
||||
not useful. It cannot tell a CIP supply line from a product line, read a
|
||||
diameter label, or recognise a Russian service word.
|
||||
2. **The P&ID is not to scale.** Matching equipment tags between the P&ID and
|
||||
the layout, distances between the same pairs of tanks differ by factors from
|
||||
0.1 to 5.6; inside one room the P&ID is a consistent ~1.28× stretch of the
|
||||
plan, inside another it is rearranged outright. A pipe length measured on
|
||||
that P&ID is a length of drawn line, not of pipe — and the hand-made pipe
|
||||
takeoff was measured exactly that way, while the hand-made cable takeoff of
|
||||
the same project states in its own method sheet that the P&ID is unscaled.
|
||||
No tool warned anybody. This track makes that check a tool.
|
||||
3. **The layout lies about its units.** `INSUNITS` says inches; the geometry is
|
||||
millimetres (a 5.9 km wall total, a 150–280 m building). One stray entity
|
||||
drags the extents to −6,450 km, and two copies of the plan sit side by side
|
||||
in model space.
|
||||
4. **Real drawings are big.** The two DXFs are 55 MB and 188 MB; `drawing_open`
|
||||
refuses anything over 50 MB, and reading 35,000 entities one COM call at a
|
||||
time takes minutes where a DXF snapshot takes half a second.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
**In:** six tools (§12), three `topo_*` critique focuses, the `office` extra
|
||||
(`openpyxl`), a DXF-snapshot reader for the live engine, the raised size limit,
|
||||
synthetic fixtures that reproduce the field findings, two benchmark tasks,
|
||||
correctness checks and a local field-test script.
|
||||
|
||||
**Out of 1.6 (recorded):**
|
||||
|
||||
- Plant 3D `.dcf` reading (track D, next).
|
||||
- Cable cross-section *standards*: no IEC 60364-5-52 table is authored. A
|
||||
section is chosen only from a rule table the caller supplies (the shop's own
|
||||
rule); without one, no section is proposed. Voltage-drop calculation is out.
|
||||
- Routing pipes around walls and equipment. A layout length is an orthogonal
|
||||
estimate between connected equipment (§9) — the method the user's cable
|
||||
takeoff already uses — and is labelled an estimate.
|
||||
- OCR of raster underlays and text inside PDF-imported hatch geometry.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
engineering/understand/
|
||||
__init__.py
|
||||
snapshot.py one bulk read of a drawing: ezdxf directly, or a DXF snapshot of
|
||||
the live document through Document.Export (§4)
|
||||
vocab.py multilingual keyword tables (EN / TR / RU / NL / DE): disciplines,
|
||||
services, equipment kinds, room words, supply/return words
|
||||
labels.py parsers: diameters (Ø51, Ø25,4, SMS51, DN20, 20x27, 1,5"),
|
||||
equipment tags, electrical data (kW, phases, voltage, +N, VFD)
|
||||
units.py declared vs inferred units, robust extents, outliers
|
||||
clusters.py spatial clusters of model-space content (sheets, plan copies)
|
||||
describe.py drawing_understand: the report assembled from the above
|
||||
scale.py drawing_scale_check: tag matching and ratio statistics
|
||||
network.py line networks: junction merge, T-split, fittings bridged, runs,
|
||||
service and diameter per edge, equipment attachment
|
||||
route.py orthogonal lengths: Manhattan distance, rectilinear MST
|
||||
takeoff.py pipe_takeoff and cable_takeoff rows, statuses, totals
|
||||
report.py the takeoff workbook (Metraj / Özet / Kontrol / Metodoloji), CSV
|
||||
diff.py entity signatures, matching, the change report
|
||||
topology.py dangling endpoints, near misses, interior crossings
|
||||
critique.py the three topo_* focuses
|
||||
draw.py the async layer: snapshot, markup (revision clouds), exports
|
||||
scripts/field_test_takeoff.py local only: runs the takeoffs on the user's files
|
||||
```
|
||||
|
||||
Everything except `snapshot.py` and `draw.py` is pure: entity records in,
|
||||
results out. The entity record is a small frozen dataclass (type, layer,
|
||||
handle, geometry, text, block name, attributes) filled once per drawing, so a
|
||||
35,000-entity drawing is read once and every analysis runs in memory.
|
||||
|
||||
## 4. Reading a drawing (the snapshot)
|
||||
|
||||
- **Headless:** `ezdxf.readfile` on the path, or the document already open.
|
||||
- **Live:** `Document.Export(<path without extension>, "DXF", <selection set of
|
||||
all>)` writes a DXF copy **without renaming the document** — measured on
|
||||
AutoCAD 2026 on 2026-09-24: the document kept its name, an unsaved document
|
||||
exported too, and ezdxf read the result. The extension argument must be
|
||||
`"DXF"`; `".dxf"` fails with *Invalid argument* (measured). The snapshot goes
|
||||
to a temporary folder and is deleted afterwards. Whether paper-space content
|
||||
and xrefs reach the snapshot is measured by the plan's first live task and
|
||||
stated in the tool docstrings.
|
||||
- `MAX_DXF_BYTES` default rises from 50 MB to 512 MB, and the refusal names the
|
||||
size and the variable (a real layout here was 188 MB).
|
||||
|
||||
Every tool in this track is read-only unless it says otherwise (only
|
||||
`drawing_diff(markup=True)` and the takeoff export write anything, and the
|
||||
export writes a file, never the drawing).
|
||||
|
||||
## 5. `drawing_understand`
|
||||
|
||||
One call, one report, every claim with its evidence and a confidence:
|
||||
|
||||
- **Units:** declared `INSUNITS` versus the unit the geometry implies (text
|
||||
heights, typical wall / door sizes, dimension values, overall size). A
|
||||
disagreement is a warning with the numbers, never a silent correction.
|
||||
- **Extents:** declared versus robust (quantile) extents; the outlier entities
|
||||
that stretch them, by handle.
|
||||
- **Clusters:** separate bodies of content in model space — sheets, plan copies,
|
||||
details — each with its box, its entity count and its most telling labels.
|
||||
- **Layers:** every layer with its entity count and drawn length, and the
|
||||
discipline / service it most likely carries (piping · product, CIP supply,
|
||||
CIP return, glycol, steam, condensate, water, air, drain; electrical; walls;
|
||||
equipment; annotation), from the multilingual vocabulary and the texts found
|
||||
on it; confidence 0.9 for a direct keyword, lower for weaker evidence.
|
||||
- **Equipment tags**, **rooms** (label words in five languages — *room*, *oda*,
|
||||
*mahal*, *помещение*, *ruimte* — plus faces of wall layers through track F's
|
||||
finder), **text languages**, **blocks** (named / anonymous, kinds by name),
|
||||
**layouts** and **title-block attributes**.
|
||||
|
||||
## 6. `drawing_scale_check`
|
||||
|
||||
Compares a drawing with a reference drawing of the same plant. Equipment tags
|
||||
found once in each are matched; for every pair of matched tags at least 2 m
|
||||
apart the ratio of their distances is taken. The report gives the matched tags,
|
||||
the median ratio, the interquartile range, the share of pairs within ±10 % of
|
||||
the median, the same statistics per room or cluster, and a verdict:
|
||||
`to_scale` (≥ 80 % of pairs within ±10 %; the factor is reported, so a drawing
|
||||
at a different but uniform scale is recognised), `schematic` (≤ 50 %), or
|
||||
`partly` in between. Tag labels are not equipment centres; the ±10 % band and
|
||||
the 2 m floor are there to absorb that, and the report says so.
|
||||
|
||||
## 7. Line networks (the shared engine)
|
||||
|
||||
Segments (LINE, LWPOLYLINE, POLYLINE, ARC) on the chosen layers become a graph:
|
||||
endpoints merged within a tolerance, segments split where another ends on them
|
||||
(T-junctions), and a segment that ends at an inline fitting (an INSERT whose
|
||||
box it touches — reducer, valve, coupling) continued through it. A connected
|
||||
component is a **run**.
|
||||
|
||||
- **Service** of an edge: the layer's vocabulary class, overridden by supply /
|
||||
return words within a search distance (`ПОДАЧА`, `ВОЗВРАТ`, `BESLEME`,
|
||||
`DÖNÜŞ`, `SUPPLY`, `RETURN`, `AANVOER`, `RETOUR`), carried along the run. Layer
|
||||
alone is not trusted to split CIP supply from return — the field P&ID proved
|
||||
why.
|
||||
- **Diameter** of an edge: the diameter label nearest to it within the search
|
||||
distance (*direct*), else carried along the run from a labelled edge until a
|
||||
reducer or a conflicting label (*continuity*), else *unassigned*. Labels are
|
||||
kept exactly as drawn: `Ø51`, `SMS51`, `DN20`, `Ø25,4` and `Ø25` are different
|
||||
sizes unless the drawing equates them.
|
||||
- **Equipment attachment:** a run end inside (or within tolerance of) the box of
|
||||
the geometry a tag labels is attached to that tag.
|
||||
|
||||
## 8. `pipe_takeoff`
|
||||
|
||||
Rows by room × service × diameter, in the layout of the user's own takeoff
|
||||
workbook:
|
||||
|
||||
- **Lengths.** `length_source="auto"` (default) runs the scale check between
|
||||
the P&ID and the layout. If the P&ID is `to_scale`, runs are measured on it.
|
||||
If not, the length of each run is estimated on the layout: the positions of
|
||||
the tags it connects, joined by a **rectilinear minimum spanning tree**
|
||||
(Manhattan distance; a two-ended run is |Δx| + |Δy|), plus
|
||||
`vertical_allowance` per connection (default 0, stated in the report). The
|
||||
schematic length drawn on the P&ID is kept alongside as a comparison column,
|
||||
never as the answer. `"pid"` and `"layout"` choose a source and the report
|
||||
says which was chosen; `"pid"` on a P&ID the scale check calls `schematic` is
|
||||
refused unless `force=True`. With no layout given, `auto` measures on the P&ID
|
||||
and every row carries `scale_verified: false`, stated on the method sheet.
|
||||
An orthogonal route runs along x first, then y — deterministic, and stated.
|
||||
- **Status:** **A** — every end on a tag found on the layout and the diameter
|
||||
direct; **B** — diameter by continuity; **C** — not routable (an end not on
|
||||
equipment, a tag missing from the layout): reported on the *Kontrol* sheet
|
||||
with its schematic length only, counted in the total and flagged.
|
||||
- **Rooms:** a routed length is apportioned to the layout rooms its orthogonal
|
||||
route crosses (room polygons from the layout's wall faces and room labels);
|
||||
without room polygons, to the room label nearest the run.
|
||||
- **Scope:** `services` defaults to product + CIP supply + CIP return (the
|
||||
user's takeoff); utilities are included only on request.
|
||||
- **Output:** a workbook — *Metraj* (rows with direct and continuity lengths
|
||||
split, status, allowance, allowance-included), *Özet* (by service, by
|
||||
diameter, by room), *Kontrol* (unassigned diameters and unroutable runs with
|
||||
the reason), *Metodoloji* (generated: sources, the scale-check result, the
|
||||
rules applied, the tolerances) — with Turkish, English or Russian headers
|
||||
(`lang`), written as XLSX when `openpyxl` is installed (the new `office`
|
||||
extra) and as CSV always. `allowance` defaults to 0.20 and is its own column,
|
||||
never mixed into the net length.
|
||||
|
||||
## 9. `cable_takeoff`
|
||||
|
||||
One row per electrical load, the user's cable method made repeatable:
|
||||
|
||||
- **Power and supply** from the P&ID's electrical texts near each tag (`P =
|
||||
18,5 кВт`, `3 ф. 400 В + N + PE`, `VFD`, in Russian, Turkish and English
|
||||
spellings). A load with no stated power keeps an empty power cell and an open
|
||||
item — **power is never invented**.
|
||||
- **Panel** from the layout's electrical callouts (`Wiring to CP1` and its
|
||||
variants) or the P&ID; panel names normalised (`CP 1` → `CP-1`).
|
||||
- **Length:** Manhattan distance on the layout from the tag to its panel, plus
|
||||
the allowance, rounded up to the metre (`ROUNDUP(length × (1 + allowance))`,
|
||||
as the user's sheet does). With no layout given, lengths stay empty and each
|
||||
row gets an open item — the P&ID is not a length source for cables.
|
||||
- **Room totals** of known power, with the rule for panel packages versus their
|
||||
child motors stated in the method sheet so no load is counted twice.
|
||||
- **Section:** only from a caller-supplied `section_rules` table; otherwise
|
||||
blank.
|
||||
- Same workbook shape and languages as §8, with an *Open items* list.
|
||||
|
||||
## 10. `drawing_diff`
|
||||
|
||||
Two revisions of one drawing (paths, or the current document against a path).
|
||||
Entity signatures (type, layer, geometry rounded to a tolerance; text content
|
||||
and position; block name, insertion, rotation, scale and attributes) are
|
||||
matched exactly first, then by handle, then by type + layer + nearest position
|
||||
within a move tolerance; what is left is added or removed. The report gives
|
||||
counts by layer and type, the changed entities with what changed (moved by, text
|
||||
from → to, attribute from → to, radius from → to), block-definition changes and
|
||||
a capped list per category. `markup=True` draws revision clouds around the
|
||||
clusters of change on the newer drawing with track G's revision tools — the
|
||||
only write in the tool, and only on request.
|
||||
|
||||
## 11. `drawing_topology_check` and the `topo_*` focuses
|
||||
|
||||
Dangling endpoints, near misses (two ends, or an end and a segment, within a
|
||||
gap tolerance but not joined) and interior crossings (two segments crossing away
|
||||
from their ends), on the layers given or, by default, on the network layers
|
||||
`drawing_understand` classifies with confidence ≥ 0.9. The three critique
|
||||
focuses (`topo_dangling_endpoint`, `topo_near_miss`, `topo_interior_crossing`)
|
||||
run the same engine under `focus=None`, **excluding layers a semantic focus
|
||||
already covers** (P&ID line layers → `pid_*`, architectural walls → `arch_*`),
|
||||
so one defect is never scored twice; on a drawing made entirely by this server
|
||||
they are silent.
|
||||
|
||||
## 12. Tool surface, packs, lean
|
||||
|
||||
| Tool | Pack | Writes? |
|
||||
|---|---|---|
|
||||
| `drawing_understand` | core (lean) | no |
|
||||
| `drawing_scale_check` | core | no |
|
||||
| `drawing_diff` | core | only `markup=True` |
|
||||
| `drawing_topology_check` | core | no |
|
||||
| `pipe_takeoff` | `plant` | a workbook file |
|
||||
| `cable_takeoff` | `plant` | a workbook file |
|
||||
|
||||
**+6 → 247 tools.** New pack `plant` (track D's readers join it later). Lean
|
||||
gains `drawing_understand` (→ 65). Aliases in five languages (metraj, metre
|
||||
cetveli, boru listesi, kablo listesi, спецификация, ведомость, takeoff, BOQ,
|
||||
fark, karşılaştır, revizyon farkı, compare, DWGCOMPARE, birim, ölçek, scale
|
||||
check).
|
||||
|
||||
## 13. Refusals
|
||||
|
||||
Before any work: a missing or unreadable file, a DXF over the size limit (with
|
||||
the variable named), `lang` outside tr/en/ru, a non-positive tolerance or
|
||||
allowance outside 0–1, `length_source="pid"` with a P&ID the scale check calls
|
||||
schematic *without* `force=True` (the refusal quotes the statistics), an XLSX
|
||||
export without `openpyxl` (the `xlsx_write` capability, naming the extra; CSV
|
||||
still written).
|
||||
|
||||
## 14. Confidentiality
|
||||
|
||||
Client drawings, spreadsheets and anything derived from them that identifies
|
||||
the client or its business — file names, client and site names, takeoff
|
||||
quantities — never enter the repository, its tests, its
|
||||
benchmark results or its commit messages. The fixtures are **synthetic**
|
||||
(§15). `scripts/field_test_takeoff.py` runs the tools on files the user points
|
||||
it at and writes its report next to them, outside the repository; its output is
|
||||
never committed.
|
||||
|
||||
## 15. Evidence
|
||||
|
||||
- **Synthetic plant pair** (`tests/fixtures/plant_pair.py`): a generator that
|
||||
builds a schematic P&ID DXF and a layout DXF of one small plant with known
|
||||
answers — equipment tags present in both, the P&ID deliberately stretched
|
||||
1.3× in one room and rearranged in another, service layers with English /
|
||||
Dutch names, Russian supply / return words, `Ø` / `SMS` / `DN` / `a x b`
|
||||
labels, a reducer, an inline valve, electrical texts in Russian, `Wiring to
|
||||
CPn` callouts, a layout with `INSUNITS` set to inches over millimetre
|
||||
geometry, one far outlier entity and two plan copies.
|
||||
- **Benchmark tasks** (matrix v7, 21 tasks): `takeoff_roundtrip` — the scale
|
||||
check calls the synthetic P&ID `schematic`, every pipe run's layout length
|
||||
equals the hand-computed rectilinear MST exactly, diameters and services match
|
||||
the generator's truth, and the cable rows equal the generator's Manhattan
|
||||
lengths with the allowance rounded up; `understand_foreign` — units flagged,
|
||||
the outlier found, both plan copies separated, the service layers classified.
|
||||
- **Correctness checks** (+5): `scale_check_detects_schematic`,
|
||||
`pipe_takeoff_rmst_exact`, `cable_takeoff_roundup`, `diff_detects_known_edits`
|
||||
(a move, a text change, an attribute change, an add and a delete, and nothing
|
||||
else), `topology_known_defects`.
|
||||
- **Field test** (local, not committed): `pipe_takeoff` and `cable_takeoff` on
|
||||
the user's real pair, compared with their hand-made workbooks; the difference
|
||||
is expected where the hand-made pipe takeoff measured the unscaled P&ID, and
|
||||
the report explains each row.
|
||||
- **Live smoke** `scripts/smoke_understand_com.py`: the snapshot, the takeoffs
|
||||
and the diff on AutoCAD 2026, every document closed.
|
||||
Reference in New Issue
Block a user