Commit Graph
79 Commits
Author SHA1 Message Date
Umut CANandClaude Opus 5 4fe2d963c0 fix(task-16): custom keys mirror AutoCAD's full AddCustomInfo rule, deletes are exempt, keys match case-insensitively
The 2b9791f fix mirrored only the characters the first review happened to
measure ('=' and ';'), so `custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}`
still raised from AddCustomInfo('A:B') on the live engine after subject and
AAA were applied -- the same half-write for 11 other keys. Re-measured on
AutoCAD 2026 by sweeping every character 0x20-0x7E through a scratch
document: exactly thirteen printable characters are 'Invalid key' anywhere
in a key (" * , / : ; < = > ? \ ` |); control characters, internal spaces,
a no-break space and unicode are accepted; leading/trailing whitespace
(space, tab, NBSP) is refused. `_CUSTOM_KEY_FORBIDDEN` is now that set,
the internal-NBSP refusal (wrong: measured accepted) is gone, and the
validator / tool docstrings state the measured rule instead of a closed
enumeration that was false.

A delete (`{key: None}`) is exempt from the AddCustomInfo syntax rules:
RemoveCustomByKey never validates syntax (measured: 'Key not found', never
'Invalid key'), and 2b9791f ran the check before the None branch, so a key
an ezdxf script wrote ('A=B', ' PAD ') showed in drawing_properties_get and
could never be removed. The line-break rule still applies both ways.

Also measured: custom keys are case-insensitive in AutoCAD (AddCustomInfo
over an existing case-variant is 'Duplicate key'; SetCustomByKey and
RemoveCustomByKey match either spelling and keep the stored one). The COM
routing was case-sensitive, so `{'PROJECT': ...}` over 'Project' went to
AddCustomInfo and failed mid-write -- the same class. Both engines now
match existing keys casefolded (update in place under the stored spelling,
never a second tag), and a request naming two case-variants is refused.

Tests: parametrized validator sweep over all thirteen characters, the
accepted set (control chars, NBSP, unicode, the other printables), the
delete exemption headlessly on a DXF carrying such keys and on the fake
SummaryInfo, case-variant refusal, and case-insensitive routing against a
fake that raises 'Duplicate key' / 'Key not found' like AutoCAD. Confirmed
live through the real ComBackend on a scratch document (Close(False)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:37:44 +03:00
Umut CANandClaude Opus 5 2b9791fbcd fix(task-16): custom properties refuse pre-R2004 headlessly, line breaks, and AutoCAD's invalid keys before any write
Three header-only-write / half-write classes in drawing_properties_set:

1. Measured: ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after
   $LASTSAVEDBY, which does not exist before R2004, so on an R12 (AC1009) or
   R2000 (AC1015) document the write reported ok, drawing_properties_get
   echoed it from memory, and drawing_save_as (version kept) wrote nothing --
   the class f3ff703 removed for CANNOSCALE. The headless engine now refuses
   with a ValueError naming the DXF version and "save as R2004 or newer",
   before custom_vars is touched; the dwgprops capability reason no longer
   claims custom_properties_supported unconditionally.

2. A line break in a custom key or value passed validation, ezdxf wrote it
   unescaped, and the saved DXF failed to reopen (DXFStructureError) -- the
   whole drawing lost, reported as success. Refused by name in the shared
   validator.

3. Measured on AutoCAD 2026: AddCustomInfo rejects keys with leading/trailing
   whitespace and keys containing '=', ';' or a no-break space as 'Invalid
   key' -- mid-write, after the summary and earlier keys were applied, as an
   unnamed COM error -- while the headless engine wrote them. The shared
   validator now mirrors those rules, so both engines refuse before writing
   (internal spaces, tabs, unicode and 300-char keys stay accepted, as
   AutoCAD accepts them).

Pinned by save+reopen tests on R12/R2000 (refused, file reads back empty),
R2004 (round-trips), the newline case (refused, the file still opens), and
the fake-SummaryInfo COM test (no ActiveX call reaches SummaryInfo).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 18:45:02 +03:00
Umut CANandClaude Opus 5 10a7372ce7 feat(settings): drawing_properties_get/set — SummaryInfo on COM, custom properties on both engines
Summary fields are refused headlessly with capability dwgprops before any
write; custom properties are $CUSTOMPROPERTYTAG/$CUSTOMPROPERTY pairs and
survive save + reopen. Validation is shared, by name, and never str()-coerces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 16:59:19 +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 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 f3ff703144 fix(task-14): refuse CANNOSCALE on an R12 file headlessly instead of losing it at save
Measured: an R12 (AC1009) document has no OBJECTS section, so ezdxf exports
neither AcDbVariableDictionary/DICTIONARYVAR nor the seeded SCALE objects, and
drawing_save_as keeps AC1009. `_set_annotation_scale` reported `ok: True,
changed 1:1 -> 1:50` and the file reloaded as 1:1 -- the header-only write
that vanishes on reload, the class this task exists to remove, and a breach
of the backend's own R12 honesty convention (viewport_set_scale / lock).

The write is now refused with a ValueError naming R12 and "save as R2000 or
newer", after parse_scale (a malformed value is still named as such) and
before either dictionary is touched, so a refused call leaves the root
dictionary as it found it and drawing_settings still applies the sibling keys
(limits / grid / ortho survive R12). The refusal is listed with the others in
the contract and server docstrings. Pinned by two tests that save, reopen and
read the R12 file back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 10:14:05 +03:00
Umut CANandClaude Opus 5 909bc47e5e fix(task-14): seed AutoCAD's default scale list before adding an annotation scale headlessly
An ezdxf-authored drawing has no ACAD_SCALELIST entries, and
`_ensure_scale_entry` appended only the requested scale. AutoCAD repopulates
its default list only when the dictionary is *empty*, so the saved file
opened with a one-entry scale list and `SetVariable("CANNOSCALE", "1:1")`
failed with `Error setting system variable` -- the annotation scale could
no longer be changed in AutoCAD, while the tool reported `ok: True` and the
test pinned the degraded state (`len(keys) == 1`).

An empty list is now seeded with AutoCAD's own default metric list before
the requested scale is considered: the 17 entries a fresh AutoCAD 2026
drawing carries (`Documents.Add()` saved as DXF, MEASUREMENT 1), in that
order and under AutoCAD's own keys A0 ... A9, B0 ... B6. New entries take
the next key in that sequence (`_scale_list_key`) instead of being keyed by
their name; presence is still decided by group 300, so a non-empty
AutoCAD-authored list is never re-seeded or duplicated. Verified live: the
headless file now opens in AutoCAD 2026 with the 17 keys, CANNOSCALE 1:50
current, and switching to 1:1 and 1:100 works.

Tests: the fresh-drawing test asserts the seeded names and keys, that
setting 1:50 appends nothing (it is a default), that 1:25 lands under B7
once, and that the list survives save + reopen with 1:1 present; a new test
pins that *reading* the annotation scale creates nothing (the read side
stays pure, AutoCAD fills an empty list itself); the AutoCAD-keyed list test
now expects the A3 key. The `drawing_settings` docstring says what each
engine does and that AutoCAD refuses the live write on a paper-space layout
with no active viewport (measured).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 09:49:38 +03:00
Umut CANandClaude Opus 5 33604c4408 fix(task-14): read and write system variables on the ActiveX document, not the Application
`system_get_variable` / `system_set_variable` called `GetVariable` /
`SetVariable` on the object `_acad_app()` returns. Those are `AcadDocument`
members; `AcadApplication` has neither, so on a live seat every call died
with `AttributeError: AutoCAD.Application.GetVariable` before anything
reached AutoCAD -- `drawing_settings` (all keys, read and write) was dead on
the COM engine although both engines were promised. Measured on AutoCAD
2026: `hasattr(app, "GetVariable")` is False. Both methods now resolve the
document through `_acad_doc()`, whose "No drawing is open" refusal is what a
seat without a document reports. Verified live through `ComBackend`:
LIMMAX reads (277.0, 202.5); `drawing_settings({"annotation_scale": "1:50",
"polar_angle": 45, "polar": True})` applies and reads back 1:50 / 0.02 /
45.0 / True in model space.

The test fake shipped with the task had GetVariable / SetVariable on the
*Application*, so the gate asserted a call shape the real object does not
have. `_FakeApp` now carries only `ActiveDocument` + `Documents.Count` and
raises `AttributeError` for the two names, exactly like the real
Application; the sysvar store is a `_FakeDoc` that the real `_acad_doc()`
resolves. Two regression tests: the document is the target (call list), and
no open drawing raises the clear RuntimeError. The older
`tests/test_com_sprint3.py` coercion test pinned the same wrong target and
now fakes the document.

`tests/test_com_backend.py::_measure_backend` replaced the module-level
`_acad_doc` without ever restoring it, so a MagicMock document leaked into
every later test of the session -- invisible while nothing downstream
resolved the document itself, fatal once the sysvar path did. An autouse
fixture in that module restores it.

Not touched (same root cause, other owners): `app.GetVariable` /
`app.SetVariable` in `_ensure_linetype_loaded`, `drawing_info`,
`drawing_audit`, `system_run_command` and `system_run_lisp` in
backends/com_backend.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 09:49:25 +03:00
Umut CANandClaude Opus 5 df2ab91900 fix(task-14): return the plain point list from COM system_set_variable, not the VARIANT
The tuple/list branch put the win32com VARIANT built for SetVariable into the
returned dict. The system_set_variable tool returns that dict verbatim under
output_schema {"type": "object"}; pydantic cannot serialise a VARIANT, FastMCP
drops the structured content and the client sees "outputSchema defined but no
structured output returned" -- after AutoCAD had already applied the write, so
every raw point-variable write (LIMMIN, LIMMAX, GRIDUNIT, SNAPUNIT, INSBASE)
was a retry/undo trap on the live engine while the audit log said TOOL OK.

The VARIANT is now only the SetVariable argument; "value" carries the
coerced Python list. The fake-ActiveX test asserts the reply, and a new
Client(server.mcp) test drives the tool through the output schema so the
serialisation path is covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:48:09 +03:00
Umut CANandClaude Opus 5 e8e7e3c524 fix(task-14): match ACAD_SCALELIST entries by their group-300 name, not the dictionary key
`_ensure_scale_entry` decided "already present" with `scales.get(name)`, a
lookup by DICTIONARY key. AutoCAD saves ACAD_SCALELIST keyed A0, A1, ... with
the scale name only in the SCALE object's AcDbScale group 300, so on any
AutoCAD-authored drawing (whose default metric list already carries 1:1 ...
1:100, 2:1) the check never matched and every `annotation_scale` write
appended a second SCALE object with the same name.

Presence is now decided by iterating the list and reading each entry's
group-300 name (`_scale_entry_name`, which reads the DXFTagStorage subclass
ezdxf loads SCALE into). A genuinely new entry is still keyed by its name
(the plan's test contract); if that key is somehow taken it falls back to
the next free `A<n>` slot like AutoCAD.

Regression test seeds an A0/A1/A2-keyed list the way AutoCAD writes it and
asserts: an existing 1:50 is reused (no new key, exactly one object named
1:50), a missing 1:25 is appended exactly once and is idempotent, and the
file round-trips with a clean audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:31:13 +03:00
Umut CANandClaude Opus 5 df39b21f2e feat(settings): drawing_settings gains limits, grid/snap, ortho/polar, psltscale, annotation scale, unit formats and current styles
Headlessly, grid/snap are stored on the active VPORT, CANNOSCALE in
AcDbVariableDictionary (+ an ACAD_SCALELIST entry), and LIMMIN/LIMMAX on
the model-space layout as well as the header — measured: the header alone
is overwritten at save. Registry-saved variables (AUTOSNAP, POLARANG, …)
are refused with capability registry_sysvar instead of vanishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:14:31 +03:00
Umut CANandClaude Opus 5 8d9c444b76 bench: matrix v4 adds pid_roundtrip; correctness suite grows to 29 with three P&ID checks
tasks_v4.py extends v3 with one task the reader can fail on its own: the
example spec is drawn through pid_from_spec's code and read back by the
graph builder, which never sees the spec. The runner, the chart and the
doc-number gate now default to v4; --matrix v3 / v2 reproduce the earlier
sets, and tests/test_benchmark_v3.py labels its full-matrix run "v3"
explicitly now that the default has moved.

The plan's adapter check expected the two unnumbered lines to come back
with no line number; draw_line builds one from the default number_format
(bare sequence when size/service/spec/insulation are empty), so the task
asserts the two authored numbers on the two process lines and sequence-
built XDATA numbers on the other two instead.

The published A/B report still covers 26 checks; Task 21 re-runs it
against v1.5.1, which is what clears the remaining doc-number drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 13:06:52 +03:00
Umut CANandClaude Opus 5 e911a3a8fb feat(server): TOOL_PACKS — advertise only the vertical packs a client uses
TOOL_PACKS=all (default) | comma list of core,pid. core is everything not
claimed by another pack and can never be dropped; unknown entries are
ignored with a warning. Pack hiding composes with TOOL_PROFILE=lean (the
lean set now carries pid_symbol_insert / pid_line_draw / pid_graph, 50
tools) and ENABLE_3D. system_about reports {enabled, available, ignored};
cad_batch's unknown_tool rejection names the TOOL_PACKS gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 12:55:38 +03:00
Umut CANandClaude Opus 5 42154e025b fix(task-19): the contact-sheet test fails when the symbols are missing
render() reported symbols = len(all_specs()) without rendering anything, and
the plan's bytes > 10_000 threshold was calibrated for view_screenshot's
16 x 9 in figure, not the 20 x 22 in sheet, where a labels-only render is
85 KB. A builder that draws nothing passed the test.

render() now reports what was drawn: symbols counts the specs whose insert
left an entity with a real extent in the modelspace (blank names the rest;
a spec break is one vertical line, so either axis counts), and ink is the
share of PNG pixels that are not the background colour. The test renders a
labels-only baseline with insert_symbol monkeypatched to draw nothing and
checks that baseline reports zero symbols, is past the byte threshold, and
carries less ink than the real sheet (measured 0.0234 vs 0.0273).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 12:42:54 +03:00
Umut CANandClaude Opus 5 c441609254 feat(pid): pid_tag_parse, catalogue and ISA-5.1 resources, prompt rewritten onto the tools
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 12:29:04 +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 8b6f355c3d fix(task-14): polyline bulges leave the backends in WCS, so a mirrored arc reads on its true side
The per-vertex `bulges` list was handed out in the entity's own frame next
to `points` that had been translated to WCS. A mirrored LWPOLYLINE
(extrusion / Normal -Z, what `entity_mirror` produces) is a reflection,
which reverses every arc's sense, so the reader tested endpoints against
the arc's mirror image: a line ending on the true apex dangled, a line
ending on empty paper made a junction, and `length` (frame-invariant)
said nothing. Both engines had it — ezdxf emitted `get_points("xyb")`
raw, COM returned `GetBulge` raw after reflecting `Coordinates`.

`ocs.wcs_bulge(extrusion, bulge)` now negates the sign for a left-handed
frame (the same rule `wcs_arc_angles` applies to an ARC's endpoints) and
both `_entity_info` paths route bulges through it whenever they translate
the points. Fenced by an end-to-end mirrored-pipe graph test, the COM
`_entity_info` fake with Normal -Z, and a boundary-level OCS test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 00:44:34 +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
Umut CANandClaude Opus 5 4b6abf7b92 feat(pid): pid_symbol_list and pid_symbol_insert — blocks with tags, ports and XDATA
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 16:44:06 +03:00
Umut CANandClaude Opus 5 8a3b8735b9 fix(task-10): nozzle stubs meet the drawn outline, impossible capsules and nozzle typos refused
Nozzle stubs started at the bounding-box edge, so on a dished head, a
hopper's sloped wall or a cone roof they floated off the vessel (default
horizontal_vessel N3/N4 by 0.8 mm, reactor N3/N4 by 0.06 mm, unbounded for
user fractions). The port now stays STUB outside the bbox and the stub is
drawn from there in to where its axis-aligned line meets the outline, via a
new geometry.scan_hits helper; jacketed reactors' stubs pass through the
jacket to the wall.

validate_vessel_params refuses height <= width for vertical capsules
(vertical_vessel, column, reactor), width <= height for horizontal_vessel
and height <= width/6 for a cone-roof tank: those folded the heads through
each other and produced geometry outside the declared bbox. Unknown keys
inside a nozzle dict are refused naming nozzles[i] and the key, like the
top-level parameters already were.

Tests: a fuzz over every accepted vessel shape x 28 nozzle positions checks
the declared bbox and that each stub's inner end lies on the outline (exact
point-to-arc/segment distance); the default-symbol repros and the refusals
are pinned. 137 of the new cases fail against the previous module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 16:26:00 +03:00
Umut CANandClaude Opus 5 661258004f feat(pid): six parametric vessel symbols with nozzle ports
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 16:12:15 +03:00
Umut CANandClaude Opus 5 240d9b3d9d feat(pid): rotating, heat-transfer and inline equipment symbols
Task 9 of the v1.6 P&ID plan: engineering/pid/symbols_equipment.py fills
the stub with three ISO 10628-2 families registered on import — rotating
(centrifugal_pump, pd_pump, compressor, fan, agitator, ejector), heat
(shell_tube_hx, plate_hx, air_cooler, fired_heater) and misc inline
fittings (filter, strainer_y, reducer with shape=concentric|eccentric,
flange_pair, spectacle_blind, orifice_plate with a signal port,
drain_vent). Every port sits on the declared bbox edge and every symbol
carries the TAG/DESC attribute pair; the catalogue harness now covers
103 specs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 16:02:54 +03:00
Umut CANandClaude Opus 5 352ce21030 fix(task-8): actuators stack on the body's real top and every stem meets its glyph
Two authoring slips in the valve x actuator composition:

- The diaphragm actuator's stem stopped at y=4 while the dome's flat base
  sat at y=6, so the canonical control-valve symbol (PID_VALVE_*_DIAPHRAGM,
  9 catalogue entries) shipped with its dome floating 2 mm above the stem.
- Actuators were placed at a fixed y=4 regardless of the body, so on the
  needle body (top 5.6) the piston/motor/solenoid glyphs were drawn over
  the knob and the hand bar across the needle's stem; the stem line(0,0,0,4)
  also duplicated the needle's own axial line (and the butterfly disc).

Placement is now body-relative: `_body` reports `axis_top` (where its own
geometry on x=0 ends) and the stem runs from there to the glyph base at
`body_top + STEM_CLEARANCE`; the diaphragm dome's base is the stem top
(base + DIAPHRAGM_YOKE), and the FAIL attribute follows the glyph base.
The reference 8x4 bodies keep their published numbers (globe+diaphragm
signal at y=9.0, bbox top 9.0).

Tests: per-combination check that the stem starts at the body's derived
axis top, the glyph's lowest edge equals the stem top, the glyph clears
the body, and the signal port is the topmost edge; plus a catalogue-wide
no-duplicate-primitives gate. 33 combinations fail on the old geometry.
The plan's Step 3 snippet is synced with the fixed module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:51:20 +03:00
Umut CANandClaude Opus 5 41e60bd4d0 feat(pid): 11 ISO 10628-2 valve bodies composed with 6 ISA-5.1 actuators
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:37:46 +03:00
Umut CANandClaude Opus 5 cb23f31700 fix(task-7): instrument bubble outline per ISA-5.1 Table 5.4.1, location lines clipped to the outline
build_instrument drew a circle for every type, so `computer` shipped as
circle + hexagon and `plc` as circle + square + diamond (10 of the 20 bubble
blocks wrong, and the circle poked 0.67 mm outside the hexagon's flat top).
Spec 4.4: discrete = circle; dcs = circle inside a square; computer =
hexagon; plc = diamond inside a square. The outline is now chosen per type.

Location lines were always drawn from -r to +r, overshooting the hexagon's
slanted sides by 0.46 mm at y=+/-0.8 and the circle's chord by 0.06 mm.
They now end on the outline (`_bubble_half_width`): chord for the circle,
full width for the square, `r - |y| / tan 60` for the hexagon.

Tests now assert the exact primitive list per type and that every location
line stays on/inside its outline (both fail against the previous code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:27:23 +03:00
Umut CANandClaude Opus 5 6cccadee84 feat(pid): symbol catalogue framework — instruments, markers, connectors
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:14:55 +03:00
Umut CANandClaude Opus 5 a0b16f399b feat(pid): ACADMCP_PID payload encoding with 255-char chunking
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:04:01 +03:00
Umut CANandClaude Opus 5 f4c2f27bee fix(task-4): refuse non-finite/non-numeric XDATA and budget 16 KB per entity
encode_values() let strings and nan/inf through float() into 1010 point
coordinates and never finiteness-checked bare 1040 reals, so
entity_set_xdata(handle, app, [["nan", 1]]) or [float("inf")] wrote a
literal "nan"/"inf" into the DXF (and every undo snapshot) and read back
as null. Numbers now go through the same rule block_specs._scalar applies:
int/float only, bool refused, math.isfinite, error naming values[i][j].

The advertised 16 KB limit was per call per app; AutoCAD's is per object
across every application (xdroom/xdsize), so three 15 KB apps on one
entity passed headless and would fail SetXData live. check_entity_budget()
now sums the entity's existing XDATA under every other app (ACAD's DSTYLE
overrides included) plus each 1001 marker with AutoCAD's resbuf sizes,
and both engines run it before registering the APPID or writing; removal
is never refused. The result's "bytes" is that same accounting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 14:49:43 +03:00
Umut CANandClaude Opus 5 41d6d3505a fix(task-4): refuse LF/CR in XDATA strings before any write
encode_values() accepted strings containing "\n" or "\r". ezdxf writes
1000-tag values verbatim, so the separator split the tag pair: the saved
DXF failed to reopen (DXFStructureError) and, because every undo and
transaction snapshot is also a DXF save, transaction_rollback broke after
a single such write. Both engines route through encode_values, so the
refusal (ValueError naming values[i]) now happens there, before either
backend touches the document. Only LF and CR split a DXF tag; tabs and
the other Unicode separators round-trip and stay allowed.

Tests: pure encoder refusal for \n, \r, \r\n; other control characters
still typed; ezdxf refuses-then-rollback-and-reopen round trip; COM fake
sees no SetXData / RegisteredApplications call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 14:33:00 +03:00
Umut CANandClaude Opus 5 cda8b69dba feat(entity): entity_get_xdata / entity_set_xdata on both engines
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 14:16:12 +03:00
Umut CANandClaude Opus 5 a84cae2641 feat(blocks): block_define — typed primitives + ATTDEFs on both engines
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:58:09 +03:00
Umut CANandClaude Opus 5 94840042a5 merge: track-a task 12
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:44:57 +03:00
Umut CANandClaude Opus 5 18f8daef5f merge: track-a task 6
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:44:53 +03:00
Umut CANandClaude Opus 5 75610d5fd4 merge: track-a task 2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:44:49 +03:00
Umut CANandClaude Opus 5 0a0be740bd fix(task-2): refuse multi-line / trailing-caret TEXT and ATTDEF strings before write
`text`, ATTDEF `prompt` and `default` were only isinstance(str)-checked, so
'A\nB' and '10^' passed validation and ezdxf's DXFAttr fixer
(`fix_one_line_text`) rewrote them on `add_text` / `add_attdef` at DEBUG
log level — the declared ATTRIB value and the one the P&ID reader gets back
differed silently, and COM (raw string) diverged from ezdxf. Same hole the
tag check closed in 4158575, left open on its three siblings.

A `_one_line(value, where)` helper now refuses '\n', '\r', any other C0
control, and a trailing '^' (ezdxf's own `is_valid_one_line_text` rule),
naming `entities[i]: 'text'` / `attdefs[i]: 'prompt'|'default'`. Tests cover
the refusals, the strings ezdxf keeps verbatim ('10^2', 'Ø20 H7', '%%C20'),
and an ezdxf round-trip proving accepted values are stored unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:34:47 +03:00
Umut CANandClaude Opus 5 2833e1bc93 fix(task-12): refuse non-finite points, non-axis ports and unfillable number formats
Three ways `engineering/pid/lines.py` could emit a silent wrong result or
an opaque crash instead of an honest refusal:

- `format_line_number` only recognised a token that was exactly `{field}`;
  `P-{seq:04d}`, `{size}"-{service}` or `{bogus}x` were copied verbatim,
  braces and all, into the line number. Tokens are now parsed with
  `string.Formatter`: one named known field per token, a format spec is
  honoured (`{seq:04d}` -> `0007`), literal decoration around the field
  survives with it and drops out with it, and conversions, positional
  fields, multi-field tokens, unbalanced braces and specs the value cannot
  take are all refused by token name.
- `route` never checked finiteness: a NaN `end` collapsed the U candidate
  to a valid two-point "straight" path ending at the stub point. Every
  coordinate of start/end/waypoints and the stub are now checked and the
  refusal names the offending `end.x` / `waypoints[1].y` / stub.
- A non-axis port direction (45.0, 90.5, nan) raised `KeyError` from the
  AXIS lookup. `axis_direction()` now normalises with a 1e-6 deg tolerance
  for engine drift and otherwise raises ValueError naming `start_dir` /
  `end_dir` and how to resolve it (rotate to a multiple of 90 or pass
  waypoints).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:30:02 +03:00
Umut CANandClaude Opus 5 2344b3b9d4 fix(task-6): bind the invalid-tag fixture to the reported letter and position
Every grammar error is prefixed with the tag's own letters, so the old
single-letter fragments (FCI/"I", PSVX/"X", ...) matched whatever the
grammar said: a mutation that dropped the letter and position from every
error left 9 of 11 invalid rows green. The fixture now quotes the error
text the spec demands ("unexpected letter I at position 3", "... after
valve V", "trailing O (open/closed) ...", "needs at least one function
letter after F"), and the test pins it to the single first error.

ABCDEF-106 was mispinned as a grammar rejection of "D": six letters
exceed the regex cap, so it never reached the grammar. It is now pinned
as a format rejection, and ABCDE-106 carries the "letter D at position 4"
case.

A fixture lint rejects any fragment that is a substring of its own tag
and requires grammar rows to name a letter, position or trailing rule,
so a future row cannot reintroduce the hole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:27:59 +03:00
Umut CANandClaude Opus 5 41585757c9 fix(task-2): close the trailing-newline tag hole and gate DXF-illegal layer names
Two ways a malformed block_define request slipped past the pre-write
validator and reached the engine:

* `ATTDEF_TAG_RE.match` with a `$` anchor accepts 'TAG\n' because `$`
  matches before a trailing newline. ezdxf strips the newline on write, so
  'TAG\n' + 'TAG' landed as two ATTDEFs with one tag, defeating the
  duplicate-tag refusal, and the tag-keyed attribute readers then dropped
  one silently. Now `fullmatch`.

* `_layer` only required a non-empty string, so a DXF-illegal name such as
  'BAD<LAYER' passed and ezdxf raised DXFValueError mid-loop, after the
  block existed and earlier primitives were written (with overwrite=True,
  after the old definition was already wiped). The gate now refuses on the
  same forbidden-character rule `sanitize_symbol_name` uses, exposed as
  `security.illegal_symbol_name_chars`, with a TypeError naming
  `entities[i]` and 'layer'.

Tests cover both repros and fail against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:22:56 +03:00