Three blocking review findings in engineering/mech/annotate.py.
1. draw_annotation_prims assigned ROLE_LAYER[role] without creating the
layer. EzdxfBackend._apply_attrs adds a missing layer; ActiveX does not.
MEASURED live on AutoCAD 2026 (probe: GetActiveObject, Documents.Add(),
doc.Close(False) in finally): on a scratch document whose layer table is
['0'], draw_surface_texture(ComBackend(), at=(0,0), ra=3.2,
machining='required', lay='perpendicular', allowance=0.5) raised
"AutoCAD COM error (-0x7ffdfff7)" and left one orphan AcDbLine on layer 0
— a partial write with no rollback, on exactly the foreign-drawing path
the spec names (drawing_open runs no bootstrap). The same probe after the
fix: {'ok': True, count 6, 'layers_created': ['DIM','TEXT']}, model space
['AcDbLine' x4 on DIM, 'AcDbText' x2 on TEXT], layer table ['0','DIM',
'TEXT']. New helper ensure_annotation_layers() creates every target layer
up front, before the first entity, with its ENGINEERING_LAYERS colour /
linetype / lineweight; the result reports layers_created.
2. side='both' drew the two elementary symbols 0.4h apart — the other-side
one still stood on the (correctly omitted) identification line, so a
double-V butt weld came out as two detached halves that were not even
symmetric about the reference line. ISO 2553 places them back-to-back on
the continuous reference line: the other-side baseline is now 0.0 when
there is no identification line. tests/test_mech_annotate.py asserted the
wrong apex (-0.4*h), so the gate certified the defect; that test is
rewritten to assert the mirror pair, plus a double-fillet case.
3. For side='other' the size and length were written above the reference
line while the symbol hung below the identification line — 'a6' sat where
an arrow-side throat dimension sits, on a joint with no arrow-side weld.
Both texts now sit beside their own symbol (0.25h clear of the line it
stands on, on the symbol's side). New test covers side='other' with size
and length, and pins arrow/both unchanged.
All five new or rewritten tests fail against the pre-fix module (the layer
one with the measured 'Key not found' refusal, via a fake that models only
that measured behaviour) and pass after. tests/test_mech_annotate.py: 29
passed. Full suite: 3326 passed, 6 pre-existing failures unrelated to this
change (verified identical on the branch tip with the change stashed),
test_release_consistency.py::test_document_numbers_match_the_tree
deselected as instructed. No tool registration changed, so
docs/tool-inventory.json regenerates byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
ISO 1302:2002's seven transcribed symbol-proportion rows (text height ->
line width d', short-leg height H1, minimum height H2) drive the 60 deg
vee, the machining-required bar at H1, the machining-prohibited circle
inscribed in the vee, and the all-around circle at the kink; the Ra/Rz
values, the process, the lay symbol and the machining allowance land
around the extension line, which grows with the widest of them. A text
height outside the seven rows is refused by name rather than
interpolated, and ISO 1302 is accepted as an alias designation for
drawings still issued under it - the graphical symbol is the same one.
ISO 2553:2019's reference line, dashed identification line (omitted for a
symmetrical weld, as the standard permits), size written before the
symbol, length and pitch written after it, field-weld flag, all-around
circle and process tail. Six elementary symbols are transcribed - square,
single-V, single-bevel, single-U, single-J, fillet. The rest of the
elementary table is deliberately not shipped: a weld kind outside the six
is refused with the list of the six, because a guessed weld symbol is a
wrong instruction to a welder.
Both tools place the symbol on a plain leader - a line plus an arrowhead
with the proportions the backend's own mleader already draws - rather
than through leader_create_mleader, which refuses empty text and would
hang an MTEXT label off every symbol.
Proportions neither standard dimensions (the all-around circles, the
flag, the identification line's dashes, the text pitch and the character
width estimate) are named constants documented as this implementation's
declared choices, never as transcribed values.
No new ActiveX member is called: both tools compose LINE / ARC / CIRCLE /
LWPOLYLINE / TEXT through the entity-creation contracts both engines
already implement, so no live COM probe was needed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
engineering/mech/ opens with the four modules every track-B/G branch imports,
so the four worktrees can be cut from this commit and none of them has to
invent a name the others will conflict with.
- primitives.py: the typed descriptions the pure modules emit (Line, Arc,
Circle, Poly, HatchArea, Text, DimIntent) with bbox / translate / rotate /
scale, and ROLE_LAYER - the one place an ISO 128 line role becomes a layer
name, so no module hardcodes a layer. The arc bbox sweeps its quadrants: a
0-180 arc reports y=+r, not its endpoints, which is what keeps the view
layout from stacking two views on top of each other.
- xdata.py: the ACADMCP_MECH codec in Track A's shape - a 1001 application
marker, 1000 chunks of at most 255 ASCII characters, the 16 KB per-entity
ceiling enforced before anything is written, and a foreign application, a
corrupt chunk stream, a wrong version or an unknown kind refused with
ValueError instead of decoded into something plausible.
- standards/__init__.py: the table registry. A size outside the coverage is
refused against the coverage; a size inside it that is not a listed row is
refused against the rows. Nothing is interpolated, an ambiguous variant is
never resolved by taking the first row, and a registration whose SOURCE does
not name the table it was transcribed from is refused.
- standards/materials.py: the section-hatch map for nine materials. ISO
128-50 standardises only the general hatching (narrow continuous lines at 45
degrees) - that is acadiso.pat ANSI31 and what engineering/gear.py already
draws for steel. The distinguishing symbols are ASME Y14.2M's section lining
as shipped in AutoCAD 2026's acadiso.pat, read off the installed file; cast
iron's departure from ASME and the absence of any mechanical symbol for
concrete and wood are stated in the rows themselves. Angles are 0.0 because
the patterns already carry 45 degrees, and the two architectural patterns'
scales are derived from the ANSI31 line spacing and recomputed by the test
rather than chosen.
No tool is registered here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
f7528b2 moved the five unit reads of apply_standard ahead of the style
stage so a refused read is a refusal with nothing written -- and then
decided the writes on those reads. Making a dimension style current
restores every DIM* variable from the style (ezdxf: _sync_header_to_dimstyle
on dimstyle_set_current; AutoCAD: ActiveDimStyle), so on a drawing whose
ISO-25/ANSI already carried another scale the gate read (1.0) equalled
the target, the DIMSCALE write was skipped and the drawing ended at the
style's scale while the report said `changed: {}` -- against the spec's
"LTSCALE 1, DIMSCALE 1" and the tool docstring. The pre-f7528b2 loop ran
after the switch and got this right; this is a regression of f7528b2.
The gate read stays (readable before anything is written); each variable
is read again after the style stage and the write is decided on that
value, so `changed` says what the write moved.
Measured on the running AutoCAD 2026 (25.1s, 2026-09-22, scratch
Documents.Add(), Close(False), real ComBackend after connect()):
ISO-25 modified to DIMSCALE 2.0 (dimstyle_modify -> {'DIMSCALE': [1.0, 2.0]}),
Annotative current, doc.GetVariable('DIMSCALE') -> 1.0
before: apply_standard(iso, layers=False)
-> {'changed': {}, 'applied': True}; ActiveDimStyle ISO-25;
doc.GetVariable('DIMSCALE') -> 2.0
after: -> {'changed': {'DIMSCALE': [2.0, 1.0]}, 'applied': True};
ActiveDimStyle ISO-25; doc.GetVariable('DIMSCALE') -> 1.0
ezdxf, same setup (dimstyle_create ISO-25 {DIMSCALE: 2.0}, Standard
current): before `{'INSUNITS': [6, 4]}` and DIMSCALE 2.0 afterwards;
after `{'INSUNITS': [6, 4], 'DIMSCALE': [2.0, 1.0]}` and DIMSCALE 1.0.
Tests: one regression per engine (both fail on the f7528b2 apply.py);
test_com_apply_standard_creates_both_styles_and_sets_units now pins the
gate reads first *and* the re-read-then-write sequence after CopyFrom,
instead of enshrining the stale-read order.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
AcadApplication has no GetVariable/SetVariable members; they live on
AcadDocument. Measured on the running AutoCAD 2026 (25.1s, 2026-09-22,
scratch Documents.Add(), Close(False)):
hasattr(app, 'GetVariable') -> False
hasattr(app, 'SetVariable') -> False
app.GetVariable('INSUNITS') -> AttributeError: ... IAcadApplication
instance ... has no attribute 'GetVariable'
doc.GetVariable('INSUNITS') -> 4
doc.GetVariable('MEASUREMENT') -> 1
doc.GetVariable('FILEDIA') -> 1; doc.SetVariable('FILEDIA', 0) -> None,
reads back 0, restored to 1
doc.GetVariable('AUDITCTL') -> 0, 'CMDACTIVE' -> 0, 'USERS1' -> ''
Fifteen call sites in backends/com_backend.py still went through the
Application inside `try/except ... log.debug`, so on a live seat they
silently degraded: MEASUREMENT defaulted every seat to metric,
drawing_info reported units from a failed INSUNITS read, drawing_audit
never set AUDITCTL, the CMDACTIVE guards of system_run_command /
system_run_lisp never fired and run_lisp never read USERS1 back. All are
rerouted through `_acad_doc()`; the try/except stays only where an
optional read is defaulted (INSUNITS -> "Unknown", AUDITCTL absent on a
host that lacks it, USERS1 after the expression ran) and the log line
says so.
Linetype loading, measured the same way: `SendCommand("_-LINETYPE _LOAD
CENTER acadiso.lin\n\n")` loads nothing with FILEDIA=0 and leaves the
command at `Enter an option [?/Create/Load/Set]:` (LASTPROMPT), which
makes the whole seat answer RPC_E_CALL_REJECTED to every client until
ESC is pressed; with FILEDIA=1 it opens the modal "Select Linetype File"
dialog. `doc.Linetypes.Load("CENTER", "acadiso.lin")` returns None and
the table grows 3 -> 4; a repeat raises 'Duplicate record name', an
unknown name 'Undefined linetype', a missing file 'File system error'.
`_ensure_linetype_loaded` and `linetype_load` now call `Linetypes.Load`
(MEASUREMENT still read on the document to pick the .lin), touch no
FILEDIA, and report a failure with the English excepinfo description.
`apply_standard` reads every variable it will write before the first
style is created, so a read the engine refuses is a refusal with nothing
written (it used to fail after ISO-25 / ISOCP were created and current).
Fakes: every application fake refuses GetVariable/SetVariable
(tests/test_styles.py's COM fixture carried them on the Application,
enshrining the wrong shape); the members exist on document fakes only.
Regression tests per rerouted site: linetype load via Linetypes.Load with
MEASUREMENT on the document, drawing_info units from the document,
AUDITCTL set/restored on the document, CMDACTIVE/USERS1 on the document,
apply_standard refusing before any write.
Live end-to-end with the real ComBackend on a scratch document:
apply_standard('iso') -> INSUNITS 4, ISO-25 (already in acadiso.dwt,
created: false) and ISOCP (created: true) current, second call created:
false / changed: {}; linetype_load('CENTER') -> Linetypes 3 -> 4, FILEDIA
stays 1, repeat already_loaded, 'NOSUCHLT' refused by name;
_ensure_linetype_loaded('HIDDEN') -> 3 -> 4; drawing_info units 'mm';
drawing_audit AUDITCTL 0 -> 0; system_run_command ok;
system_run_lisp('(+ 40 2)') -> '42'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
flatten_bulges chops a semicircle into an even number of chords (36 for
r=50, 12 for r=5), so a flattened vertex lands exactly on the apex — the
axis of symmetry, the one place a P&ID line most plausibly meets a
crossing jump. segments_cross treats a vertex hit as a junction, so both
neighbouring chords dropped it and the arc contributed 0: the (45,0)->(55,0)
bulge -1 jump reported crossings 0 at x=50 and 1 at x=50.3 or x=49, the
plan's own (0,0)->(100,0) semicircle 0 at x=50, and the mirrored frame
(extrusion -Z) 0 at x=-50.
count_crossings now takes bulges= (per-chain DXF bulge lists, zero-padded)
and tests every bulged edge as its exact arc through the new
segment_arc_crossings — circle/segment intersection restricted to the
sweep — with the same junction rule as the straight case: a hit within
_EPS of the segment's ends, of the arc's own two ends, or a tangent, is
not a crossing. Nothing along the sweep is a vertex any more. The arc
parametrisation is factored into _arc_of_bulge and shared with
flatten_bulges, which keeps its promised signature and its ezdxf pin but
no longer feeds the crossing count. draw_line passes the vertices and
bulges through unflattened.
Pinned in tests: the apex case on the semicircle (x=50, y 20->60), the
r=5 jump at x=50 / 50.3 / 49 on +Z and -Z frames, the junction cases
(T-touch on the arc, through the arc's endpoint, chord side, tangent
along the apex, in-and-out at y=4.9 counts 2), and 4x400 seeded random
segment/arc pairs against ezdxf's ConstructionArc.intersect_line (a
20 000-case run in the worktree found 0 mismatches). The eight new
assertions fail on the flattened rule and pass on the exact one.
Headless finding, headless evidence; no new ActiveX call was written, so
the shared live AutoCAD was not touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
A foreign or hand-edited ACADMCP_PID payload (from: 'garbage', seq: True)
made the next pid_line_draw raise TypeError; payload_port_refs /
payload_seq now accept only well-formed values and a reader result never
raises. An existing polyline with a bulge was tested as its chord, so a
line through the arc reported crossings 0; flatten_bulges (pure, pinned
against ezdxf.math's flattening) feeds count_crossings on both engines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
pid_graph step 5 ran the TEXT label fallback for every edge without a
number. Once signal lines stopped carrying a fabricated number (3c0babf),
every pneumatic/electric run drawn by pid_line_draw entered it, and a
transmitter-to-controller run 12 mm above a labelled header came back as
line_number="100-P-1", number_source="label" — on both engines, with no
confidence field on edges to flag it.
The fallback now skips an edge that carries its own record (an
ACADMCP_PID payload: its `number: null` is the answer) and any
signal-class edge, payload or layer-classified (`signal_unknown`
included): LINE_NUMBER_RE is the pipe grammar, so a match near a signal
line always belongs to a neighbouring pipe. A foreign process line still
takes its label; a verbatim number on a signal line still reads back
from XDATA.
Tests: the finding's layout in test_pid_graph (engine-drawn signal beside
a labelled pipe, verbatim signal number, foreign electric and
signal_unknown lines beside a labelled foreign pipe) and the line_list
row in test_pid_deliverables — three of the four fail on the old reader.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Documents.Add(<file>) accepts only a genuine DWT. Measured on AutoCAD 2026:
Add(templates/iso_a3_pid.dxf) returned the default acadiso drawing (layers
['0'], Layout1/Layout2) exactly as Add() and Add(<missing.dwt>) did, so
drawing_new(template=<bundled name>) reported ok:true with source
'bundled_dxf' over a blank document, and the explicit .dxf path route was
just as blank. ComBackend.drawing_new now converts any non-.dwt template
through AutoCAD itself (Documents.Open read-only -> SaveAs ac2018_Template,
older constants tried next -> Close(False)), caches the .dwt in the temp
folder keyed on path/size/mtime, hands Add the .dwt, and refuses a missing
template file instead of letting Add default silently. The tool result
carries template.dwt / template.dwt_cached. Re-measured end to end through
server.drawing_new on the real ComBackend: ansi_b_mech -> 11 layers, tab B,
DIMTAD 0 / DIMTIH 1 / DIMEXO 1.5; iso_a3_pid -> 17 layers, tab A3, second
call reuses the cached .dwt (0.8 s conversion once). Fake-ActiveX tests pin
the call shape and that the conversion document never stays open.
Headlessly the build's fallback wrote spec 4.1 only as header DIM variables,
and the renderer folds six of them, so DIMTAD/TIH/TOH/EXE/EXO/GAP/LUNIT/LWD
were a no-op: ansi_b_mech dimensioned with ISO text placement while
system_get_variable and AutoCAD said ANSI. The fallback now also stores each
value on the Standard dimstyle; the five DXFs are rebuilt (--check passes)
and a test reads the effective values off a DIMENSION drawn from each
template and asserts header and style agree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
An ISA-5.1 signal line has no pipe line number, yet every pneumatic /
electric line got the bare sequence as its number and a label, and ate a
process sequence number. Signal classes now leave line_number None (a
verbatim one is still honoured), seq None, no label. A radial port aimed
at the far end even with waypoints, so the first segment cut through the
bubble; aim_points (lines.py) aims at the first/last waypoint for both
draw_line and the dry run.
Merge note: benchmarks/adapters/autocad_mcp_pro.py (final group's file)
had its two _task_pid_roundtrip assertion lines moved to the new truth —
the signal row is unnumbered, the connector row is 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
SECTION 19 grows to five tools with drawing_template_list; drawing_new takes a
bundled name (iso_a3_mech, iso_a1_arch, iso_a3_pid, ansi_b_mech, ansi_d_arch)
or a .dwt/.dxf path and reports template: {name|path, path, source}.
scripts/build_templates.py builds templates/*.dxf through EzdxfBackend
(layer set, header DIM variables until group S's styles land, drawing_settings,
sheet tab, ISO 5457 frame on A3, page_setup_apply) and --check pins them
reproducible. One drift the plan's same-process measurement could not see is
fixed at the source rather than masked: ezdxf orders CLASSES by set iteration,
so two processes disagreed on LAYOUT / ACDBPLACEHOLDER; the section is re-keyed
(required list first, then by name) before the save.
The root .gitignore ignores *.dxf as generated output; templates/*.dxf is
un-ignored there, since the bundled set is the one DXF that is committed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
EzdxfBackend.drawing_export_pdf routed layout=None / layout="Model" through
_msp(), which follows the current tab, so with a sheet current the "Model"
PDF was that sheet's paper-space content with no refusal — the same defect
finding 3 named on COM, still live on the headless engine. Measured: circle
in model space, 390x270 rectangle on an A3 sheet, sheet current — the Model
row's /MediaBox was 176.1x121.9 mm (the rectangle) instead of 121.9x121.9
(the circle). The model target is now doc.modelspace() whichever tab is
current, on both entry points.
The test that vouched for this only asserted the row's shape (paper is None,
true by construction). It is replaced by two tests that prove content: the
Model row's mediabox with a sheet current equals the one with Model current
and carries the circle's square aspect, and drawing_export_pdf(path) with no
layout does the same. Both fail without the backend change. plot.py's
docstring now says which engine does what.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two review findings on 32821c6.
1. Once the shielded rollback body runs after a real client cancellation it
can fail (the COM hard timeout, _safe_send_command's own deadline, an
ezdxf quarantine refusal). That RuntimeError escaped run_spec as an
ordinary error and the mcp lowlevel server, which had already answered
the cancelled request, tried to respond a second time: `assert not
self._completed` in mcp/shared/session.py escaped the server task group
and the whole session died (measured through the real transport: the next
system_status was never answered and Client.__aexit__ re-raised the
AssertionError). run_spec now decides by the exception it is handling:
after a cancellation the rollback error is logged and the cancellation is
what propagates, so the SDK's cancellation suppression applies; after any
other error the rollback error propagates as before, with the original
chained as its context.
2. The docstring promised the rollback on both engines, but on the live
AutoCAD 2026 ComBackend.transaction_rollback sent `_UNDO B` with no UNDO
Mark in the stack (StartUndoMark/EndUndoMark form an undo *group*, not a
Mark), AutoCAD answered "This will undo everything. OK? <Y>", SendCommand
blocked at that prompt, _run gave up after 60 s, the entities stayed and
every COM client was rejected until a human pressed ESC. transaction_begin
now types `_.UNDO _M` before StartUndoMark (refused with ok:false while
CMDACTIVE, where the Mark would be swallowed as input), and
transaction_rollback ends the group and sends `_.UNDO _B`, which returns
exactly to that Mark - also on an empty transaction, where `_UNDO 1` would
be one step too many. A rollback with no transaction is refused the way the
headless engine refuses it, because an unmarked Back is that prompt.
Measured with the real ComBackend in a scratch Documents.Add() document on
the running AutoCAD 2026 (closed with Close(False)): begin 31 ms ->
entity_create_line + circle -> rollback 63 ms -> 3 entities back to 1,
CMDACTIVE 0, no prompt; an empty begin/rollback leaves the older line;
begin/commit unchanged.
Tests: the two failure modes on run_spec (anyio scope cancellation with a
raising rollback completes the task group and logs; a ValueError with a
raising rollback still raises the rollback error with the ValueError as
context), the real-transport case (client.cancel mid-run, rollback raises,
the next request is answered, cleanup through the real rollback), the COM
shape now pins `_.UNDO _M, StartUndoMark, EndUndoMark, _.UNDO _B`, and
test_com_sprint3 grows a Mark-keeping fake document whose unmarked Back
fails the way the prompt does: begin before the group, rollback consumes the
Mark, rollback without a transaction sends nothing, begin refuses on
CMDACTIVE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Three review findings against SECTION 19 on the live engine, each measured
on AutoCAD 2026 in a scratch document with the operator's BACKGROUNDPLOT=2:
- plot_style_list read Preferences.Files.PrintStyleSheetPath; the ActiveX
member is PrinterStyleSheetPath, so the AttributeError was swallowed and
every row came back installed=None. Renamed in the backend, the server
docstring and the test fake (which faked the same wrong name). Live: 13
rows, all 9 catalogue ctb files installed, four .stb files as installed.
- ComBackend.drawing_export_pdf returned ok before any file existed under
background plotting, so batch_plot died on path.stat() with an unhandled
FileNotFoundError and a later foreground plot raised E_FAIL. The export
now sets BACKGROUNDPLOT=0 around PlotToFile (restored after), honours the
method's bool and reports ok only with the file on disk (bytes included).
batch_plot turns a backend RuntimeError or a phantom ok into a per-row
ok:false, keeping the rows already plotted.
- With a sheet current, the Model row was that sheet plotted again: the COM
export switched tabs only when a layout was named. layout=None now makes
Model current (restoring the tab), and batch_plot names every layout,
Model included, so both engines plot the tab the row is labelled with.
Live: Model row /Title (Model) at 210x297 beside the A3 sheet, the active
tab and BACKGROUNDPLOT restored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
user_pick_point / user_select answer {cancelled: true} on ESC (DISP_E_EXCEPTION)
and {timed_out: true} past COM_CALL_TIMEOUT; preferences_set refuses read-only,
unknown and out-of-range keys before any ActiveX call.
The read-only key is Files.PrinterStyleSheetPath, not the spec's
PrintStyleSheetPath: IAcadPreferencesFiles has no member of that name
(measured live on AutoCAD 2026 / R25.1), and a misspelt key would make
preferences_get() raise on every live call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
The MCP SDK cancels a request by cancelling an anyio scope, and anyio
cancellation is level-triggered: task.cancel() is re-issued on every loop
iteration while the task is inside the cancelled scope. run_spec's
`except BaseException: await backend.transaction_rollback()` was therefore
cancelled at its first await, before the rollback body ran. Measured through
the real transport (fastmcp.Client + notifications/cancelled): five symbols
left in an open transaction on ezdxf; on the COM shape EndUndoMark / _UNDO B
never sent while the backend's `finally` already cleared _transaction_active,
so the next transaction_begin nested a StartUndoMark. A native Task.cancel()
(what the plan's Step-4 note reasoned about) is delivered once and clears,
which is why raising CancelledError from a mock passed trivially.
The rollback now runs inside anyio.CancelScope(shield=True). The COM hard
timeout (asyncio.wait_for inside ComBackend._run) still fires inside the
shield, measured at 0.62 s with a 0.5 s deadline, so a hung rollback cannot
freeze the server.
Tests: the mock-raise test is replaced by real cancellations that fail on the
unshielded code — a cancelled anyio task-group scope (parametrized alongside
the native Task.cancel() case), the real transport via Client.cancel(request
id) polled mid-run, and the COM shape (real ComBackend._run, a real
single-thread executor and the real transaction_rollback over a recording
ActiveX document, with a call in flight when the scope is cancelled) which
asserts StartUndoMark, EndUndoMark, _UNDO B land and the next begin is fresh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two review findings, both root causes.
1. `_sync_header_to_dimstyle` copied the DIMSTYLE entry's ezdxf-internal
arrowhead spelling (OBLIQUE) into the DXF-facing $DIMBLK/$DIMBLK1/$DIMBLK2
header, so the saved file named a block it did not hold (the blocks are
_OBLIQUE / _DOT) and `system_get_variable("DIMBLK")` answered "OBLIQUE"
headlessly against "_OBLIQUE" on COM and in the same engine's own
`dimstyle_list` row. The header write now goes through
`reported_arrowhead` — the same read-side rule `_dimstyle_value` applies.
Pinned by header, `system_get_variable`, save/reopen (`ezdxf.readfile`
header equals the block the file holds) and the Restore-to-Standard
empty-string case in `test_builtin_arrowheads_render_save_and_report_one_spelling`.
2. The `mleaderstyle` capability refusal rested on a false engine boundary.
acax25enu.tlb (AutoCAD 2026) exposes IAcadMLeaderStyle with Name,
ArrowSize, LandingGap, TextHeight, TextStyle, ... — exactly what
`Dictionaries.Item("ACAD_MLEADERSTYLE").Item(i)` returns — and
IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle") creates one.
COM `mleaderstyle_list` now reads the four values off those objects
(`values_available: True`); COM `mleaderstyle_create` validates every value
(`validate_mleaderstyle`, shared with ezdxf), checks the clash and the text
style before AddObject, then writes the four properties. The contract
method is `@abstractmethod` again, the key is gone from both capability
maps, the fake ActiveX surface models the real call shape, and the spec
(§4.2, §8.1, §9) states the ActiveX route.
Live AutoCAD was not running on this machine (GetActiveObject: operation
unavailable; no acad.exe), so the COM route is type-library-verified and
fake-tested, not executed live — scripts/smoke_settings_com.py (Task 24)
owns that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
_num let NaN/inf (and a 400-digit int, as OverflowError) through, so an
INSERT was placed at [nan, 0] and the refusal, when it came, named the
line instead of equipment[0].x. run_spec caught Exception only, so a
CancelledError mid-run left the placed symbols inside an open transaction
(an open undo mark on COM). Finite check in _num; except BaseException.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two measured root causes on the headless engine, both silent:
1. page_setup_apply wrote PLOTSETTINGS group 147 (unit_factor) as 1.0 for
every scale. Under USE_STANDARD_SCALE AutoCAD's plot engine scales by
group 147 alone (a 1:10 sheet with code 21 and 147 = 1.0 plotted a
100-unit line at 99.99 mm while the row, `applied` and 142/143 said
1:10; patching 147 to 0.1 alone gave 9.99 mm). AutoCAD writes 147 as
numerator / denominator (measured live on AutoCAD 2026 for fit, 1:1,
1:2, 1:10, 1:50, 2:1, 10:1: 1.0, 1.0, 0.5, 0.1, 0.02, 2.0, 10.0). The
writer now stores 75, 142/143 and 147 from the one resolved ratio on
every route, so no reader can disagree with the plot.
2. DXF_STANDARD_SCALE_TYPE and _plot_scale followed ezdxf's STD_SCALES,
which has no 19 = 1:5 and numbers everything above 18 one lower than
the ObjectARX StdScaleType enum AutoCAD actually writes (1:10 = 21,
1:20 = 23, 1:50 = 26, 1:100 = 27, 2:1 = 28, 10:1 = 31 — measured via COM
SaveAs DXF). An AutoCAD-authored 1:50 sheet listed as "1:100" and a
1:10 sheet rendered at 1:16. papers.py now authors the full file enum
(DXF_STD_SCALES, codes 1-33 with AutoCAD's imperial names), the writer
uses the same codes ActiveX uses, and nothing goes through ezdxf's
table. 1:5 stays on the custom route on both engines (the live setter
refuses 19) while the reader knows code 19 for files AutoCAD wrote.
Tests: the plan's `1:50 -> 25` assertion was the stale numbering and now
pins 26; a pure-data test pins the enum against the measured codes and the
ActiveX numbering; a parametrised writer test checks 75/142/143/147 for
every catalogue scale; a reader test stores the scale exactly as AutoCAD
saves it (including codes 19 and 22 the writer never emits) and checks
page_setup_list and _paper_frame report and render it at its own scale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Three authored claims in SYSVAR_CATALOG were false against the engines:
- HPANG said "degrees" but ActiveX GetVariable/SetVariable (and AutoLISP
getvar) hold radians - measured on AutoCAD 2026 in a scratch document:
SetVariable(pi/4) reads back 0.7853981633974483 on both paths. The row
now says radians, like POLARANG.
- ANGBASE said "radians" with both engines True, but the DXF header stores
$ANGBASE in degrees (AutoCAD saved the 1.5708-radian seat value as 90.0)
and ezdxf read/wrote it raw, so the two engines disagreed by a factor of
57.3. EzdxfBackend now translates that header variable at its boundary
(_HEADER_DEGREE_SYSVARS): the tool speaks radians on both engines, the
file keeps degrees.
- OSMODE claimed engines.ezdxf True, but $OSMODE is an R12-only header
variable ezdxf never exports for R2000+: a headless write reported
ok: True and read back None after save + reopen. It is registry-saved
in AutoCAD, so it joins _UNSAVED_SYSVARS (refused with
capability: registry_sysvar; the facade's `osmode` key refuses and
snapshots None headlessly, like polar / polar_angle). Headless reads of
every registry-saved variable now report None instead of whatever the
ezdxf template holds in memory.
The gate that should have caught all three, tests/test_sysvar_catalog.py's
default round-trip, wrote 56 no-ops out of 66 and never saved: it now
writes a value that differs from the fresh document's for every writable
ezdxf-True entry, saves, reopens and reads back from the file. Two new
tests pin OSMODE's refusal and ANGBASE's radians-at-the-tool /
degrees-in-the-file split.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
An unnamed UCS (UCS Origin / 3P without saving, the common kind) has an
empty UCSNAME exactly like WCS does, so both engines answered
`current: world` while the drawing was not in WCS — verified live on
AutoCAD 2026 (`_.UCS _O 10,10,0` → UCSNAME="" WORLDUCS=0) and on the DXF
it writes ($UCSNAME='' $UCSORG=(0,20,0)). A silent wrong value.
- COM: a named match still wins; otherwise WORLDUCS (AutoCAD's own answer)
decides, and WORLDUCS=0 appends a `name: None` row built from
UCSORG/UCSXDIR/UCSYDIR.
- ezdxf: the same rule against $UCSORG/$UCSXDIR/$UCSYDIR via the new
`engineering.environment.ucs.is_world_axes`; a stale $UCSNAME falls back
to the frame instead of leaving no row current.
- Server `ucs_list` result: `current` is null for an unnamed UCS (never
`world`), plus `current_unnamed: bool` so null is unambiguous.
- Tests: helper, headless (unnamed row, stale name, and the exact AutoCAD
DXF over the wire), and COM fake reading WORLDUCS + the frame variables.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Finding 1: page_setup_list swapped size_mm for plot_rotation 1/3 but
reported the margins un-turned, so a 297 x 210 sheet carried its portrait
margins ([20, 20, 7.5, 7.5], printable 282 x 170) while ezdxf's Page and
_paper_frame plotted [7.5, 7.5, 20, 20] (257 x 195). The mapping now lives
once in papers.turned_sheet (cross-checked against Page.from_dxf_layout
for all four rotations) and is used by the ezdxf row, _paper_frame and the
COM row. page_setup_apply resets plot_rotation to 0; headlessly it now
re-writes the turned margins un-turned so the sheet keeps the margins it
had (nothing moves, changed says nothing), and on COM, where margins come
from the .pc3, the ones that moved show up in changed.
Finding 2: both engines forced plot paper units to millimetres with no
units field in the row, so an inch-authored ANSI sheet was rescaled 25.4x
with ok: True and changed naming only the media. Rows now carry
paper_units (mm | inches | pixels, DXF group 72 / AcPlotPaperUnits);
resolve_page_setup takes paper_units="mm" (default) | "inches" | None
(keep the layout's units, as AutoCAD's own Page Setup does); a move is
reported in changed and, with its 25.4x consequence and the way back, in
the new warnings list (papers.page_setup_warnings, identical on both
engines).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
90 authored rows (type, range/enum, default, meaning, saved-in, per-engine
support, friendly key); every ezdxf claim is checked against the real
backend and the registry-saved set is pinned to what the backend refuses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
check_dim_value's char branch only checked len == 1, so '\n', '\t', '\x00'
and non-BMP characters passed and the headless writer stored ord(value)
unguarded: '\n' split the dimension text over two lines, NUL read back as a
comma, and U+1F600 reached $DIMDSEP / group 278 as 128512, outside the 16-bit
range and refused by AutoCAD as "not a single character". ezdxf's audit
caught none of it. The same hole 827719f closed for names and fonts, left
open for the one variable stored as a character code.
The gate now refuses code < 32, DEL, and code > DIMDSEP_MAX_CODE (0x7FFF,
the signed short DWG stores) with a ValueError naming DIMDSEP, so nothing
reaches a backend. Pinned by a parametrized refusal test (newline, tab, NUL,
DEL, U+1F600, U+8000, U+FFFF), a positive test that the middle dot, the
Arabic decimal separator and U+7FFF still pass, and the ezdxf refusal test
now proves dimstyle_create / dimstyle_modify write nothing for these.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Layer states are ACADMCP_LAYERSTATES XRECORD chunks (<= 255 ASCII chars each),
in the file and not in AutoCAD's Layer States Manager. Headless view restore
writes the *Active VPORT (ezdxf's header has no $VIEWCTR; measured). UCS is
stored and made current; tool coordinates stay WCS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Three live-measured behaviours of AutoCAD 2026 that the fake did not replay:
* GetPaperSize / GetPaperMargins answer in millimetres whatever PaperUnits
says; the 25.4 factor listed every imperial layout as [10668.0, 7543.8]
and lost the paper name. No factor now; the fake pins PaperUnits=0.
* Layout.StandardScale = 19 (ac1_5) is refused with "Invalid input" in every
condition; '1:5' is dropped from ACTIVEX_PLOT_SCALE so it goes through
SetCustomScale(1, 5) like 5:1 already did.
* Writing ConfigName replaces CanonicalMediaName with the new device's
default, and writing the old device back does not bring the media back —
the unknown-media refusal and a late refusal both left the layout on the
old device's default paper while the error claimed it was restored. The
device write now snapshots media / units / rotation and journals one
combined undo; the read-back after every unwind is compared to the
read-back before the first write and the message names what did not go
back instead of asserting it did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
The ActiveX Reference (CenterPlot Property, acadauto.chm, AutoCAD 2026):
"This property cannot be set to True on a layout object whose PlotType
property is set to acLayout." The shipped sequence wrote PlotType=5 and
then CenterPlot=True for the *default* call, so page_setup_apply(layout,
resolve_page_setup("ISO_A3")) could not succeed live — either COM raised
with nine writes landed and nothing put back, or the result claimed
applied.center=True over a read-back of False.
- resolve_page_setup resolves `center` to None for plot_area="layout"
(validated as a bool first; kept as the bool for "extents"), and
`center_applies(plot_type)` carries the rule for both read-backs.
- Both engines: page_setup_list reports `center: None` under acLayout
instead of a stored flag nothing honours; apply never sets the centre
bit / CenterPlot for a layout plot (ezdxf clears bit 4, the state
AutoCAD stores for acLayout; COM clears a stale CenterPlot=True *before*
PlotType moves to acLayout, and writes CenterPlot for an extents plot
only *after* PlotType=acExtents).
- COM: every write is journaled and unwound in reverse when AutoCAD
refuses one; the ValueError names the refused property and says the
layout was restored (or what could not be). The old device-only
rollback is the same mechanism now.
- tests: the fake enforces the documented CenterPlot rule (the shipped
sequence fails under it, which is the reviewer's repro), and can refuse
a named property; new cases pin the no-write, the ordering both ways,
the reverse unwind including SetCustomScale, and the None read-backs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two blocking review findings on the standards data layer:
* DIMTP and DIMTM were pinned to ("float", 0.0, 1e6), refusing every signed
deviation with a false "must be at least 0". AutoCAD documents both as
signed and the repo already relies on it: build_dim_override writes
dimtm=-0.022 for ISO 286 p6 and dimtp=-0.020 for f7, and
dimension_linear(fit=...) emits exactly those. A dimension style could
therefore never hold a double-positive or double-negative fit that the same
server draws correctly per dimension, and an agent obeying the message
would ship a sign-flipped tolerance. Both are now ("float", -1e6, 1e6); a
provenance test resolves the p6/f7/H7/h6 overrides build_dim_override
emits through resolve_dimstyle and pins the signs.
* 827719f made check_dim_value return DIMBLK/DIMBLK1/DIMBLK2 in the
canonical _OBLIQUE spelling and added ezdxf_arrowhead without telling
Task 10, whose plan helpers store the value verbatim and read the raw
string back - executed as written the headless drawing raised at render
and at doc.write, a user block passed the data layer and failed at save,
and ezdxf's OBLIQUE-in-session / _OBLIQUE-after-reload split made an
idempotent re-set report a change (spec section 8.3). This commit adds the
read-side twin reported_arrowhead (never raises; a foreign name passes
through), pins it with an in-session-equals-reloaded round trip, and edits
the plan: Task 9 Produces and Task 10 Consumes now name ARROWHEAD_BLOCKS,
canonical_arrowhead, ezdxf_arrowhead and reported_arrowhead; Task 10's
_write_dimstyle_values maps through ezdxf_arrowhead, _dimstyle_value and
_com_dimvar_for_report map through reported_arrowhead, a new
_require_arrowhead_blocks refuses a user block absent from doc.blocks
before dimstyle_create/modify write anything (and rewrites the name to the
block table's own spelling, measured to be what a reload reports), and two
headless tests witness the rendered INSERTs, the save/reopen spelling and
the refusal. The plan's own copy of the range table is corrected to match
the module (arrow kind, signed tolerances). The edited helper block was
executed against ezdxf 1.4.4 before being written into the plan.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Three review findings on the standards data layer, each a value the layer
let through that a backend would then write as a silent wrong number or a
drawing that can no longer be saved:
* validate_textstyle never checked math.isfinite, unlike its siblings; nan
and inf passed every comparison and reached the STYLE record verbatim.
It now has the same _number helper dimstyles and mleaderstyles use.
* The three private _ILLEGAL_NAME_CHARS copies omitted \n \r \t that
security._FORBIDDEN_SYMBOL_CHARS holds, so 'a\nb' was accepted as a text
style, DIMTXSTY or leader text style and ezdxf refused the save. The rule
now lives once in engineering/standards/names.py (fastmcp-free), the
three modules import it, and a test pins its character set equal to
security's and its output equal to illegal_symbol_name_chars.
* DIMBLK/DIMBLK1/DIMBLK2 were ("str",): any string went through, and the
test pinned '_OBLIQUE', which ezdxf's writer refuses at doc.write while
the COM engine wants exactly that spelling. They are now ("arrow",):
canonical_arrowhead accepts a built-in with or without its underscore in
any case (and '', '.', CLOSEDFILLED for the closed filled default), emits
AutoCAD's documented block-name spelling (also what ezdxf reports after a
load), and holds user block names to the symbol-name rule; ARROWHEAD_BLOCKS
is pinned equal to ezdxf's ARROWS vocabulary, and ezdxf_arrowhead is the
one translation the headless writer needs, proved by exporting all twenty
headlessly and reading each back as its canonical name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two review findings on the P&ID critique focuses.
The graph's own `nearest` is computed over every port in its grid, including
the `p1..pn` ports it invents on a promoted unknown_block, so a free end near
a sheet frame was hinted to connect to the frame — a call `pid_line_draw`
refuses, since the frame carries no ACADMCP_PID payload. Every dangling row
is now re-hinted over connectable ports only (catalogue / XDATA nodes,
declared ports), so every hint is executable.
`signal_unknown` — the class the reader assigns to a signal-layer line whose
exact class it cannot tell — was skipped by the incompatibility check
because it is not in the drawable class table. Its kind is still signal, so
a signal-layer line ending on a process port is now an error as spec §11.1
requires; only an unclassified line is skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
715c8ef applied the evidence rule in one direction only: an `unknown_block`
(any INSERT a plain LINE happened to touch, filed at 0.3) could not *create*
a pid_dangling_line, but it could still *suppress* one. build_graph attaches
a line end that lands on such a box and invents port `p1` at the line's own
end, so `graph["dangling"]` is empty and `_dangling`, which only read that
list, called the line connected. A process line from P-101's discharge to
the sheet frame (a CTO title block is a block reference) passed
drawing_critique and the drawing_finalize gate; the same line stopping 2 mm
short of the frame was an error. Spec §11.1 says the end resolves to nothing
in both cases — a frame, a logo or a note symbol is nothing the drawing
vouches for.
`_dangling` now reads two sources with one rule: the graph's own dangling
rows (filtered to evidenced edges, as before) plus every end of an evidenced
edge whose ref points at an `unknown_block`, at that inferred port's x/y.
The `nearest` hint is computed the way the graph computes it for a free end
(nearest port within 10·tolerance, same space), over recognised nodes' ports
only, so the invented `p1` can never be the suggested fix; `detail["touches"]`
names the block the end sits on. The other direction is unchanged: a note
leader whose only anchor is an unknown_block is not evidenced and stays out.
Tests: the reviewer's repro (landing on the frame → error; graph attaches it,
dangling=[]), the stop-short control, the finalize gate raising on it, and
the nearest-port hint naming a catalogue valve's port 1 mm away rather than
the frame's inferred port.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two ways the six focuses turned a reader guess into a gate-failing error:
- The "no P&ID content" guard was `not graph["nodes"]`, but build_graph
(include_foreign=True) files ANY INSERT as an `unknown_block` at 0.3 the
moment a plain LINE's end lands on its box. A bolt with a note leader on a
purely mechanical sheet became a P&ID node, the leader a foreign edge, and
its free end a `pid_dangling_line` error — so drawing_finalize (focus=None)
failed a sheet the seven mechanical focuses pass. Now `has_pid_content`
needs a recognised node (catalog / xdata / heuristic); an `unknown_block`
is never tagged or scored, and `_dangling` only reports an edge that is a
P&ID line by evidence (a class from XDATA or a P&ID layer, or an end on a
recognised node) — on a real P&ID the same leader is not dangling either.
- `_incompatible` read `port["kind"]` off inferred ports, which the graph
hard-codes to "process" for every foreign block. A signal line correctly
drawn to a CTO control valve's actuator was an error whose hint said to
redraw the control signal as a process line. Inferred ports are skipped;
only a declared (catalogue / XDATA) port kind is judged.
Tests: both repros as regressions (unit + the server drawing_finalize gate),
a foreign GATE_VALVE whose dangling line is still reported (the heuristic
path spec §9.3 pins is not silenced), a note leader on a real P&ID, and the
spec's own negative — all six return [] on the README gear sheet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
instrument_index only resolved {"node"} ends of a signal line, so a
transmitter tapping the middle of a pipe (a {"junction"} end) reported
connected_to="" and an instrument on untagged equipment fell back to the
signal line's own number — which Task 13 always stamps, so the handle
fallback was unreachable and every untagged mounting was labelled with
its wire. Spec 9.5's "line numbers" clause is exactly the junction case.
_far_ends now names the far node's tag (its handle when untagged) and,
at a junction, the line number of every line the junction joins (that
line's handle when unnumbered). Two tests exercise both mountings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Closes the four verified BLOCKING findings on pid_graph.
1. scope="all" attached across layouts: the port grid, bbox candidates,
vertex grid, junctions and text lookup were pooled from every space.
The grid is now keyed by (space, cell) and every lookup filters by the
edge's space; junctions carry `space`.
2. A mirrored INSERT (extrusion -Z, as MIRROR3D / foreign DXFs store it)
got its catalogue ports on the unmirrored image at 1.0. Both engines
report `mirrored` on INSERT properties (ezdxf: extrusion z < 0; COM:
Normal z < 0; default False); transform_port takes `mirrored` and
applies x -> -x about the WCS insertion point after rotation/scale,
direction (180 - d) mod 360 — cross-checked against Insert.matrix44.
resolve_endpoint passes it through too, so draw_line starts on the
mirror image as well.
3. The text-tag fallback searched a full circle around the box top, so a
line label or note below the symbol beat the tag above it. Candidates
must now sit at or above top - height/2 (spec §9.2 step 6), nearest
wins, and line-number texts are never equipment tags.
4. A non-uniform INSERT scale re-ran the transform with the X factor and
set `inferred` (reserved for foreign-block ports). transform_port
gains `strict=False` for the reader: point ports scale per axis
exactly; a radial port keeps min(sx, sy)·r and the node drops to 0.6
with notes ["non-uniform scale"]. Node dicts gain `y_scale`,
`mirrored` and `notes` (default []).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two verified blocking findings on pid_graph.
A foreign block's inferred ports were any line end within tolerance of
its drawn box *or strictly inside it*. A sheet border or title block is
an INSERT too, and its box encloses the whole drawing, so every end that
missed a port attached to the border instead of becoming the junction
or dangling end the tool exists to report: on a border sheet the plan's
own small P&ID read back with no junction, no dangling end and its open
signal line "connected" to A3_BORDER. The test is the boundary again, as
spec 9.2 step 3 says (within tolerance of the block's bounding-box
boundary); overshoot into a body is the tolerance's business. The
nearest-insertion tie-break stays for two boundaries that share an end.
The COM engine's `geometry_bbox` carried each definition member's own
axis-aligned box through the INSERT corner by corner, which is exact at
a right-angle rotation and too large everywhere else: a 45-degree-turned
circle of radius 5 read as 7.07, a diagonal line stood upright read 7.07
wide, and the two engines disagreed on the same drawing with no marker.
Off a right angle the member's real geometry is now measured - a LINE,
POINT and straight polyline by their vertices, a CIRCLE by the
closed-form extents of the scaled-and-turned ellipse, an ARC and a
bulged polyline by their ends and the quadrant points the sweep passes
(uniform scale, reflection reversing the sense) - and cross-checked
against ezdxf's `virtual_entities` extents at 37 and 45 degrees, scaled
and mirrored. A member that cannot be measured (text, hatch, a nested
reference, an elliptical arc) is still carried by its corners and the
box then says `approximate: true`; the graph takes the tighter wall of
that box and the attribute-inclusive `bounding_box`, both of which hold
the drawn geometry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two verified blocking findings on pid_graph.
`_point_segment_distance` put the centre of an arc larger than a
semicircle (|bulge| > 1) on the wrong side of the chord: `copysign`
discarded the sign of `radius - sagitta`, which is exactly what carries
the centre across the chord past 180 degrees. A bulge-2 arc was tested
as its own reflection, so a line ending on its true apex dangled and one
ending on empty paper made a junction. The offset now keeps that sign
and only takes the bulge's direction from the bulge.
Foreign-block port inference tested line ends against the INSERT's
`bounding_box`, which on both engines takes the ATTRIB text with it. A
TAG lettered above a valve pushed the box past the body, so a line
ending on the body's real edge on that side was *inside* the box and
reported dangling. Both engines now report `geometry_bbox` for an
INSERT - the drawn geometry only, ATTRIB/ATTDEF excluded, in WCS, omitted
when the block draws nothing (ezdxf via `virtual_entities`, COM by
carrying each definition member's box through the INSERT's origin,
scale and rotation). The graph prefers it over `bounding_box`, counts
an end as touching when it is within tolerance of the box or inside it,
and when several foreign boxes hold the end picks the nearest insertion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two reader defects, both about trusting a derived number over the drawing.
Stale junctions: endpoints were resolved before a foreign line's membership
was decided, so a P&ID line ending on a foreign line that was then discarded
(it touched no node) kept `{"junction": "J1"}` while `junctions` was empty
and `stats.dangling` was one short. Node attachment now runs first for every
edge, the foreign lines that attach are admitted, and only then are junctions
and dangling ends resolved over the edges actually in the graph.
Chord length: `length` was a chord walk over the vertices, 12.5% short on a
semicircular crossing jump with no field saying so, and junction detection
tested the chord too, so a line ending on the arc dangled. Both engines now
report per-vertex `bulges` next to `points` (COM asks GetBulge only when
`Length` exceeds the chord walk, since an arc is always longer than its
chord); the reader takes the engine's bulge-aware `length` and tests
endpoints against the arc. The chord sum survives only for an engine that
reports no length, and is then labelled `length_approximate`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Two review findings on pid_line_draw, both root-caused:
1. existing_pid_lines() filtered entity_list(type_filter="LWPOLYLINE") and
tested info.type == "LWPOLYLINE". A live LWPOLYLINE's ActiveX ObjectName
is AcDbPolyline (the repo's own 2026-08-06 measurement), which the COM
engine reports as POLYLINE, so on live AutoCAD every auto-built number was
seq 1, crossings always 0 and port_reuse always []. The reader now lists
without a type filter and accepts {LWPOLYLINE, POLYLINE, 2DPOLYLINE} plus
LINE — the same set the Task 14 graph reader keeps. The COM _entity_info
polyline branch matched only the DXF-flavoured "AcDbLWPolyline", so a live
LWPOLYLINE never got `points` either; it now accepts both spellings (with
the OCS normalisation), which is what makes the crossing count real there.
2. resolve_endpoint() applied only x_scale, so a mirrored INSERT
(entity_mirror writes y_scale = -1) resolved its port to a point off the
symbol and the line was drawn through the pump body. transform_port takes
an optional y_scale (default: uniform, signature unchanged) and applies the
full reflect-then-rotate transform to point, direction and radius; a
stretched symbol (|x_scale| != |y_scale|) has no circle for a radial port
and is refused with a clear ValueError instead of a silent wrong number.
Tests: mirror about both axes resolves onto the real geometry, a stretched
copy is refused, and a fake-COM AcDbPolyline probe sees the existing line
with its seq, vertices and from-ref.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd