Commit Graph
75 Commits
Author SHA1 Message Date
Umut CANandClaude Opus 5 a4ce46ba9b feat(mech): SECTION 23 opens with surface_texture and weld_symbol
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
2026-09-22 18:55:57 +03:00
Umut CANandClaude Opus 5 2bb8044144 feat(mech): shared foundation - primitives, XDATA codec, standards registry, ISO 128-50 materials
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
2026-09-22 17:04:59 +03:00
Umut CANandClaude Opus 5 d072df9fd4 fix(task-24a): apply_standard decides each unit write on a read taken after the dimension style switch
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
2026-09-22 12:37:33 +03:00
Umut CANandClaude Opus 5 f7528b2fa5 fix(com): every system variable is read and written on the ActiveX document, not the Application
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
2026-09-22 12:25:11 +03:00
Umut CANandClaude Opus 5 90c1194f7f merge: track-e group V
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:18:03 +03:00
Umut CANandClaude Opus 5 8e05b5e8eb merge: track-e group P
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:14:02 +03:00
Umut CANandClaude Opus 5 3278586c6e merge: track-e group C
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:12:15 +03:00
Umut CANandClaude Opus 5 0a581a8da3 merge: track-e group S
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:10:22 +03:00
Umut CANandClaude Opus 5 efcc8bbba1 fix(task-7): crossings test the exact arc, so a line through a jump's apex counts
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
2026-09-22 11:06:04 +03:00
Umut CANandClaude Opus 5 9cb4bee587 fix(pid): existing line payloads are read tolerantly and crossings follow arcs, not chords
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
2026-09-21 23:05:54 +03:00
Umut CANandClaude Opus 5 a5d166f365 feat(styles): drawing_apply_standard — ISO or ANSI styles, units and layers in one idempotent call
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:49:12 +03:00
Umut CANandClaude Opus 5 cfb5d5082c fix(task-6): the reader no longer hands a signal line the neighbouring pipe's label
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
2026-09-21 22:44:35 +03:00
Umut CANandClaude Opus 5 17f7228fd6 fix(task-19): a .dxf template really reaches the live seat; the fallback dimvars land on Standard
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
2026-09-21 22:31:44 +03:00
Umut CANandClaude Opus 5 3c0babfef1 fix(pid): signal lines carry no fabricated number; a bubble exits towards the first waypoint
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
2026-09-21 22:24:03 +03:00
Umut CANandClaude Opus 5 a76d9e08a3 feat(templates): five bundled templates built by the server's own tools; drawing_new(template=name)
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
2026-09-21 22:03:41 +03:00
Umut CANandClaude Opus 5 86c5d6ad65 fix(task-18): the headless Model plot is doc.modelspace(), not the current tab
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
2026-09-21 21:47:05 +03:00
Umut CANandClaude Opus 5 2e101b95e2 fix(task-5): a rollback that fails after a cancellation no longer kills the session, and the COM rollback goes back to an UNDO Mark instead of prompting
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
2026-09-21 21:46:08 +03:00
Umut CANandClaude Opus 5 f6951e2597 fix(task-18): the COM plot is foreground, by layout name, and confirmed on disk; plot_style_list reads PrinterStyleSheetPath
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
2026-09-21 21:36:07 +03:00
Umut CANandClaude Opus 5 f25421709f feat(pagesetup): SECTION 19 — page_setup_list/apply, plot_style_list, batch_plot with /MediaBox read-back
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 18:39:34 +03:00
Umut CANandClaude Opus 5 b83a74e969 feat(environment): system_launch, preference whitelist and operator prompts on COM; declared refusals headlessly
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
2026-09-21 18:34:09 +03:00
Umut CANandClaude Opus 5 32821c6410 fix(task-5): pid_from_spec shields its rollback so a real client cancellation rolls the sheet back on both engines
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
2026-09-21 17:01:17 +03:00
Umut CANandClaude Opus 5 e204f227b4 fix(task-10): $DIMBLK header carries the canonical arrowhead name; MLeaderStyle is authored on COM through the ACAD_MLEADERSTYLE dictionary
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
2026-09-21 16:48:20 +03:00
Umut CANandClaude Opus 5 ac1fba80b7 fix(pid): pid_from_spec refuses non-finite numbers by path and rolls back on any interruption
_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
2026-09-18 10:51:45 +03:00
Umut CANandClaude Opus 5 aeb27c252b fix(task-17): the headless page setup writes the scale AutoCAD plots by, and reads the code 75 AutoCAD writes
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
2026-09-17 15:29:47 +03:00
Umut CANandClaude Opus 5 75c13196b9 fix(task-15): angle sysvars are radians on both engines; OSMODE is registry-saved and refused headlessly
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
2026-09-17 15:20:02 +03:00
Umut CANandClaude Opus 5 64cec37cc7 fix(task-22): ucs_list decides "world" from the frame, not the empty name — an unnamed UCS is its own current row
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
2026-09-17 15:10:00 +03:00
Umut CANandClaude Opus 5 f2386a9fdd fix(task-17): a rotated sheet's margins turn with it, and a paper-units move is named in changed and warnings
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
2026-09-17 14:58:17 +03:00
Umut CANandClaude Opus 5 125a8ed3b8 feat(settings): SYSVAR_CATALOG + system_variable_describe; system_set_variable refuses out-of-range values
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
2026-09-17 14:56:25 +03:00
Umut CANandClaude Opus 5 4d246b17b8 fix(task-9): DIMDSEP refuses control characters and code points above the 16-bit DXF group
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
2026-09-17 14:52:57 +03:00
Umut CANandClaude Opus 5 dcc8f07bf4 feat(environment): portable layer states, named views and UCS on both engines
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
2026-09-17 09:47:39 +03:00
Umut CANandClaude Opus 5 376dc812a5 fix(task-17): COM page setup reads mm as mm, 1:5 takes the custom route, and a refused apply puts the media back
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
2026-09-17 09:42:13 +03:00
Umut CANandClaude Opus 5 20ca39d28a fix(task-17): a layout plot never writes CenterPlot, and a refused COM write unwinds every landed one
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
2026-09-16 21:55:14 +03:00
Umut CANandClaude Opus 5 2e2cd2773d fix(task-9): signed DIMTP/DIMTM, read-side arrowhead spelling, and the Task 10 handshake
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
2026-09-16 21:52:01 +03:00
Umut CANandClaude Opus 5 827719fdd0 fix(task-9): finite text-style numbers, one symbol-name rule, canonical DIMBLK arrowheads
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
2026-09-16 21:33:05 +03:00
Umut CANandClaude Opus 5 f46fda10cb feat(pagesetup): page_setup_list/apply on both engines — the PDF's own /MediaBox is the evidence
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:13:48 +03:00
Umut CANandClaude Opus 5 54fac471a9 feat(standards): ISO-25 and ANSI dimension, text and leader style presets as pinned data
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:11:26 +03:00
Umut CANandClaude Opus 5 d80ae17411 feat(pid): pid_from_spec — a whole P&ID in one transaction, graph and critique returned
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 12:10:14 +03:00
Umut CANandClaude Opus 5 6aded6bbf0 fix(task-16): hints only name ports pid_line_draw accepts; signal_unknown is judged as a signal line
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
2026-09-16 12:01:37 +03:00
Umut CANandClaude Opus 5 3c4fbf6c42 fix(task-16): a P&ID line ending on an unknown_block is dangling — a 0.3 guess suppresses no finding either
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
2026-09-16 11:51:22 +03:00
Umut CANandClaude Opus 5 715c8efca4 fix(task-16): P&ID focuses score evidence, never a 0.3 touch guess or an inferred port kind
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
2026-09-16 11:35:35 +03:00
Umut CANandClaude Opus 5 b4c1423c8e feat(pid): six P&ID critique focuses join drawing_finalize; plan/preflight flag a P&ID intent on the wrong layer set
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 11:17:55 +03:00
Umut CANandClaude Opus 5 346eac4ac6 fix(task-15): instrument connected_to follows junction ends and never names its own wire
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
2026-09-16 11:02:11 +03:00
Umut CANandClaude Opus 5 4e523ac6ed feat(pid): instrument index, line list and equipment list from the graph
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 10:49:30 +03:00
Umut CANandClaude Opus 5 6ad3734c47 fix(task-14): per-space resolution, mirrored inserts, tags above the box, per-axis scale
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
2026-09-16 10:35:20 +03:00
Umut CANandClaude Opus 5 ea47cbdd7e fix(task-14): a border INSERT is not a port, and a turned symbol's box is its geometry
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
2026-09-16 10:09:04 +03:00
Umut CANandClaude Opus 5 d17a26103f fix(task-14): arcs past a semicircle and foreign ports behind tag text
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
2026-09-16 09:53:24 +03:00
Umut CANandClaude Opus 5 8fcd587b5b fix(task-14): foreign-line membership before junctions, and edges measure the arc the drawing holds
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
2026-09-16 00:30:21 +03:00
Umut CANandClaude Opus 5 e246b5e86a feat(pid): pid_graph reads the drawing back with ports, junctions, dangling ends and confidence
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 00:13:42 +03:00
Umut CANandClaude Opus 5 e924b5db99 fix(task-13): mirrored-symbol ports and COM-visible existing lines
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
2026-09-15 23:48:35 +03:00
Umut CANandClaude Opus 5 e8f1a666f8 feat(pid): pid_line_draw — port-to-port orthogonal lines with class, number, markers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 23:26:09 +03:00