The review of Task 9 left four minor findings. Three change what a user reads:
- A LINE drawn back to front and moved was reported as a geometry change.
The signature already ignores a LINE's direction, so its movement now
does too: the reversed order is tried before the entity is called
reshaped.
- An INSERT carrying one attribute tag twice was read as a dict. An edit
of the first value could disappear, and since the pair then had nothing
to report, it counted as unchanged. A repeated tag is now compared as
the list of its values; a tag carried once keeps plain from / to.
- cap_report capped the three lists but not the clusters. A widespread
edit of a large drawing could return an unbounded cluster list. It is
capped like the others, with truncated["clusters"]; the markup still
clouds every cluster.
The fourth bounds work: when the whole drawing moves, every entity reaches
the position stage, and a generous move_tol gave each one hundreds of
candidates, which is n x m memory. Only the 32 nearest per entity are kept
now. A 200-line test shows each line still pairs with its own moved copy.
The first three tests failed against the previous module (the attribute
test with no changed row at all). The whole diff suite passes: 38 tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
On the live engine, take_snapshot read a .dwg path by calling
Documents.Open(path, read-only), exporting and then Close(False) on what
Open returned. If the operator already had that drawing open, that was
their document, and closing it unsaved could throw away unsaved work.
drawing_understand, drawing_scale_check, drawing_diff and the takeoffs all
read a path through this function.
The review of drawing_diff raised the risk and did not probe it live, to
avoid a modal dialog on the shared AutoCAD. The fix makes the question
moot: the path is looked up among the open documents first, case-blind as
Windows paths are, and an open drawing is exported as it stands (unsaved
edits included) and left open. Only a document opened here is closed here.
The new test, against the fake that behaves as measured, fails on the old
code with an open and a close in the log.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
ezdxf's tag reader raises StopIteration on a file that ends early, for example
after an interrupted save or a partial copy. read_snapshot caught only OSError
and DXFError, so the StopIteration escaped. asyncio.to_thread cannot hand
StopIteration back to an awaiting caller, so every track H tool reading such
a file would have hung instead of refusing it.
The review of drawing_diff found this in its own reader. The snapshot now
does the same: every parse or walk failure except MemoryError becomes the
existing "is not a readable DXF file" refusal. A new test checks this both
directly and through a worker thread with a 10 s timeout. It failed with
StopIteration before this change and passes after it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
engineering/understand/topology.py reads a line network (LINE, polylines
with true bulge arcs, ARC) on the chosen layers and reports dangling
ends, near misses (end-end and end-segment within gap, one row per pair)
and interior crossings of two straight segments of one layer. An end on
another network line is joined; an end on any curve of another layer or
inside a block reference's box is attached; a line drawn twice is not a
join, so a doubled pipe still dangles. gap defaults to 0.2 % of the
network diagonal, never under ten tol.
drawing_topology_check joins drawing_diff in SECTION 26: default layers
are those classify_layer calls piping or electrical at >= 0.9, reported
with their classification; no such layer is a refusal, not a pass.
topo_dangling_endpoint (warning), topo_near_miss (error) and
topo_interior_crossing (info) join the closed enum and run under
focus=None, excluding the P&ID and architectural layer sets, so a
pid_from_spec sheet and an arch_plan_from_spec plan get none of them;
a drawing with no candidate layer is never snapshotted.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Three blocking review findings on drawing_diff.
1. An unedited entity reported as changed with changes == {} and clouded.
The signature snaps values to the tol grid, so a value 1 ulp across a
half-grid point lands in another bucket. AutoCAD writes 16 significant
digits (0.14 + 0.005 = 0.14500000000000002 comes back as 0.145), so a
round trip split the pair, which was matched later and counted as
changed. A matched pair is now compared by difference (more than half a
tol, around the circle for angles) instead of by grid bucket, and a pair
with nothing to report counts as unchanged, with no row and no cluster.
Measured live on AutoCAD 2026 (Documents.Open read-only, select all,
Export DXF, Close(False)): before, summary changed=1, a row with
changes {} and one cluster; after, unchanged=21, changed=0, no cluster.
Over 12000 computed coordinates (a*0.01 + {0.005, 0.015, 0.105, 0.305})
795 signatures flip; before, 795 were changed, and after, 0.
2. With zero exact matches, handles were declared stable, so strangers from
two fresh files that share handles were paired and given wrong moves.
The handle stage now needs at least one exact match. The report adds
handle_evidence {exact_matches, kept_handle}.
3. A truncated DXF raised StopIteration from ezdxf's tag reader.
asyncio.to_thread cannot hand that exception to an awaiting caller, so
drawing_diff hung instead of refusing. read_revision now turns every
parse or walk failure (not MemoryError) into ValueError "not a readable
DXF (...)". tol is validated first, so its refusal keeps its own name.
Tests: grid splits by handle and by position, radius/height/rotation/bulge
split at tol=1, a real edit next to a split, no-evidence handles, a
truncated file directly and over the MCP wire with a 30 s timeout. The six
new tests fail against the previous module.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Equipment tags found exactly once in each drawing are matched (a tag
found twice - a plan copy, a legend - is reported ambiguous and takes no
part; two plan copies resolve to the cluster that matches the most
tags). For every pair of matched tags at least min_pair_mm apart on the
reference, the ratio of their distances is taken in millimetres by each
drawing's inferred unit; the median, the quartiles, the share within
+/-10 % of the median, the same per room of the reference, and the
verdict: to_scale (>= 80 %, with its factor), schematic (<= 50 %),
partly, or insufficient under three matched tags.
server.py grows SECTION 26 to two tools with drawing_scale_check (core,
read-only, tagged analysis/query); alias record, inventory, README
release snapshot 243, frozen group sizes (analysis 14).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
engineering/understand/diff.py reads each revision once into entity
records and matches them in three stages: exact signature (type, layer,
geometry rounded to tol, text, block, rotation, scale, attributes;
handles ignored), then handle while at least half of the exact matches
kept theirs, then type + layer + nearest anchor within move_tol (default
2 % of the 1st-99th percentile diagonal). The report counts changes by
layer and type, says what changed per entity (moved by, text, attribute,
radius ... from -> to), compares named block definitions by content and
groups the changes into clusters.
drawing_diff opens group D's copy of SECTION 26. It is read-only unless
markup=True, which files revision clouds around the model-space clusters
on the current document through add_revision - refused before any read
with new_path, without a valid rev, or on an engine without revcloud.
A revision path that is neither .dxf nor .dwg is refused naming the
parameter (old_path: / new_path:), like every other path refusal.
Tests: the synthetic plant P&ID edited five ways (a move, a text, an
attribute, an add, a delete) under renumbered handles reports exactly
those five edits; renumbering alone reports nothing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
A drawing somebody else made, read back as one report in which every
claim carries its evidence and a confidence; the drawing is never
modified.
- units.py: $INSUNITS against the unit the geometry implies (text
heights, wall thickness, door swings, labelled room areas, overall
size). The header is overruled only when the evidence outweighs it,
and then with a warning that quotes the numbers. Robust extents grow
from the quartile box of entity centres; what never joins is an
outlier, named by handle.
- clusters.py: separate bodies of model-space content by raster,
8-connectivity and box absorption - two copies of one plan are two
clusters.
- describe.py: layers with drawn length and the vocabulary's meaning
(name keyword, else the texts on the layer), equipment tags (one tag
lettered twice beside its symbol merged, repeats flagged ambiguous),
room labels in the wall-layer faces of engineering.arch.faces, text
languages by character class, blocks, layouts, the title block.
- server.py opens SECTION 26 (Understanding & QA) with
drawing_understand: core, read-only, tagged analysis/query. Alias
record, inventory, README release snapshot 242, frozen group sizes
(analysis 13).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Track H's foundation, landed on main before its three groups branch.
- engineering/understand/snapshot.py: one bulk read of a drawing into frozen
WCS records (OCS translated through backends/ocs.py, text decoded, an
INSERT boxed by its block geometry without its attribute texts). A DXF is
read with ezdxf after the size check; the headless engine's document is
read under its own lock; the live engine exports the active document with
Document.Export(<tmp>/snap, "DXF", <set of all>) - never renamed, deleted
afterwards - and a DWG is opened read-only, exported and closed. Measured on
AutoCAD 2026: ".dxf" is refused with Invalid argument, the file lands as
snap.DXF, model space, paper space and unused block definitions all reach
it, and the selection set does not narrow a DXF export.
- engineering/understand/labels.py: %%c, \U+2205, the U+2300 sign, o-stroke
and MTEXT format runs all read as one diameter spelling; sizes are kept as
drawn and never equated; Cyrillic look-alike capitals fold into Latin tags;
panels, wiring callouts and electrical data (kW, phases, volts, N, VFD) in
EN / TR / RU / NL / DE. A power that is not written stays None.
- engineering/understand/vocab.py: layer classification with a confidence and
the deciding keyword, supply / return words, room labels and block kinds in
five languages. A CIP layer without a direction word is not guessed.
- MAX_DXF_BYTES defaults to 512 MB (a real layout was 188 MB); the refusal
names the size and the variable. New `office` extra (openpyxl), also in
`full`; uv.lock regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd