Commit Graph
10 Commits
Author SHA1 Message Date
Umut CANandClaude Opus 5 c7dd72410a fix(task-22): live named views read and restore the display (VIEWCTR/VIEWSIZE, ZOOM Window), UCS sysvars come from the document, and a frozen current layer is thawed before restore makes it current
Four review findings, all measured by the reviewer on AutoCAD 2026:

1. ucs_list and the ucs_restore('world') CMDACTIVE guard read
   app.GetVariable, but GetVariable is an AcadDocument member — the tool
   was dead live and the guard's swallowed read forced cmd_active to 0,
   sending `_.UCS _W` into any open prompt. Both read the document now,
   and a failed CMDACTIVE read refuses instead of proceeding. The fake
   app in tests/test_views_ucs.py has no GetVariable any more, so the
   old code cannot pass.

2. view_named_save defaulted from doc.ActiveViewport.Center/Height/Width,
   which does not track the display (it answered the initial view after
   `_.ZOOM _C 5,6 20`). The current view is VIEWCTR / VIEWSIZE at the
   SCREENSIZE aspect — what `-VIEW _S` records (width 40.267 for height
   20). The fake viewport is deliberately stale so a read of it fails.

3. view_named_restore wrote Center/Height/Width into the AcadViewport;
   AutoCAD anchors the lower-left corner and widens to the display
   aspect, landing a 10x20 view at (5, 6) on VIEWCTR (20.134, 6). It now
   zooms the saved rectangle (app.ZoomWindow), which centres and fits
   like `-VIEW _R` (VIEWCTR = saved centre, VIEWSIZE = max(h, w/aspect)),
   reports `applied: "zoom_window"` and reads VIEWCTR / VIEWSIZE back.
   Headless the same reconciliation moved the centre whenever the
   *Active VPORT's aspect was rewritten from w/h (even the 1.34 default
   opened at x = 11.734): the aspect the file carries is left alone —
   it is the display's, written by AutoCAD at save — and the window is
   fitted into it; the payload adds `vport_height` / `aspect_ratio`.

4. layer_state_restore set ActiveLayer before the loop and outside the
   try; AutoCAD refuses to make a frozen layer current, so the spec's
   own save → freeze → restore path raised an opaque COM error with
   nothing applied. When `frozen` is being restored the state's current
   layer is thawed first; the write is wrapped and a refusal lands in
   `warnings`. The fake document and layer now model both refusals.

Live AutoCAD was not running in this round (GetActiveObject failed, no
acad process), so the fix is measured against the reviewer's numbers,
not re-measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 16:48:19 +03:00
Umut CANandClaude Opus 5 b1c93cdd2a fix(task-21): the applied ISO layer set is per document — it follows document_activate on ezdxf and is cleared on every document switch on both engines
`drawing_apply_iso_layers` recorded its standard in `_active_layer_set` on
the backend, and `_role_layer` reads it ahead of the PlanSpec to decide where
`dimension_auto` / `construction_xline` file their output. It was neither a
`_DocState` field nor cleared by `_reset_document_state`, so an iso13567
sheet next to a mech one pushed the mech drawing's dimensions onto
`M-DIMEN-T-N` (created on demand), silently — and `drawing_critique` could
not see it because the same stale set drove the critique. The attribute
predates the registry (v1.5 `drawing_new` did not reset it either), but the
per-document claim of Task 21 covered it and the measured attribute list
missed it.

- `_DocState.active_layer_set` + `_ActiveField("active_layer_set")` on
  `EzdxfBackend`: the set follows the active entry, bidirectionally.
- `PremiumContract._reset_document_state` clears it (declared on the mixin
  like `_plan_spec`), which fixes COM's `_ensure_document_state` switch and
  `drawing_new` / `drawing_open` / `disconnect` on both engines.
- tests: `test_active_layer_set_is_per_document` (headless registry, the
  reviewer's repro plus the reverse direction) and the COM activate test now
  also asserts the set is cleared and `_role_layer('dim')` is back on DIM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 23:17:39 +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 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 174fb8010c feat(environment): multi-document on both engines — ezdxf document registry, document_list/activate/close, drawing_close folded in
EnvironmentContract carries every member of the track E interface; the two
drawing_properties_* members are @capability('dwgprops') in this worktree only
and become @abstractmethod when group C's implementations merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:20:21 +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 ca1c79fd01 fix: seven defects an adversarial audit found, and the gates that missed them
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
Every one of these was reported ok/clean to the caller while being wrong.

Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
  TRACE and HATCH, none of which carry that attribute, so every hatch
  measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
  `filtered_by` asserted the filter had run, and COM (which reads ActiveX
  .Area) returned a different selection for the same call. It now answers
  from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
  1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
  and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
  `ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
  dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
  `xround(value, dimrnd)` whenever the attribute is present at all --
  `xround(33.333, 0.0)` is 33. So on any reopened file, including one this
  server saved, every dimension was rounded to a whole unit before DIMDEC
  formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
  *no* rounding; the sentinel is now discarded at every load site, and a real
  0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
  they died in the header: dim_decimals=-1 returned ok and then killed the
  next dimension with `ValueError: Format specifier missing precision` from
  inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
  Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).

Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
  have exactly ONE caller ... the policy is scoped to the CHANNEL". The
  premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
  build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
  treats a newline and ';' as segment separators -- so a `linetype=` argument
  carrying either injected further AutoCAD commands, and the 36-verb denylist
  never saw that channel. Guarded with the DXF symbol-name rule (the
  injection characters fall out of it rather than being enumerated) plus
  validate_path for the file argument, which was also the one path-taking
  tool reaching the filesystem unchecked. The policy comment is corrected.

Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
  checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
  rule 7 makes that call the gate an agent is told to trust. Only the
  quarantine now escapes; an ordinary broken check still degrades.
  `_check_dimension_legibility` also read `_doc` directly from the event loop
  thread, missing the gate and performing exactly the unsynchronised access
  the quarantine exists to prevent. `construction_clear` answered
  "deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
  1.0 mm critiqued clean. It now reads the effective dimension height and
  separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
  the ones that had drifted. The collected-test count (README said 1274, the
  tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
  passing plus 5 fixed is 26, not 24 -- and contradicted the report it
  linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
  already carried the dimension fixes and so never measured the v1.5.0 tag.
  Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.

Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
  PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
  shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
  absent. It now runs the same three lanes as ci.yml, Windows included.

1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:07:50 +03:00
Umut CANandClaude Opus 5 e24b46d6f0 fix: three measurement defects the adversarial triage surfaced
**COM discarded areas it had already read.** `entity_measure` fell back to
rebuilding geometry from `.Coordinates` when `.Area` *or* `.Length` was
missing, coupling two independent property reads. HATCH, REGION and 3DSOLID
expose `.Area` and neither of the others, so the fallback fired, found no
vertices, and raised "AutoCAD reported neither an area nor usable vertices" --
while holding the area. Those are the exact types the README sells the COM
backend on. Now only a missing `.Area` triggers the fallback; an absent
perimeter is reported as `None` with `perimeter_exact: False` instead of being
invented, which also removes a `round(None, 6)` TypeError waiting for the first
type that answers `.Area` and nothing else.

**A plain square hatch reported itself self-intersecting.** `ezdxf.path
.flattening()` repeats the start point, and `is_self_intersecting` excluded
adjacent edges *by index*, so with the duplicate present the first and last
real edges were cross-tested and registered as crossing. Measured: a 40x40
SOLID hatch came back `area: 1600.0, self_intersecting: True`. A closed loop
now answers the same whether or not it carries its closing vertex, and a real
bowtie is still caught (`area: 0.0, self_intersecting: True`).

**The bowtie warning never fired on the live backend.** `self_intersecting` was
only computed inside the COM fallback, so an LWPOLYLINE bowtie -- which has
both `.Area` and `.Length` and never reaches it -- returned `area: 0.0,
self_intersecting: null`. The confident zero this field exists to flag, going
out unflagged. It is now computed on the normal path too.

Also corrects a docstring written earlier today in `contracts/analysis.py`
claiming the live backend "asks AutoCAD for `.Area`, which has already applied
the [hatch island] style". Whether ActiveX's hatch area applies the style is
not verified against a live seat, and the method could not measure a hatch at
all. It now says so.

The ActiveX member profiles behind the COM tests are assumptions, not facts,
and the tests say so; a live seat settles them. `.Circumference` for CIRCLE and
`.Perimeter` for REGION are deliberately NOT added here -- writing more members
from the reference is what produced four wrong beliefs in v1.4/v1.5.

1267 passed, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:08:50 +03:00
Umut CANandClaude Opus 5 b03a7615b1 release: v1.5.0 — discovery layer, honesty debt closed, Wave A
Tool count 131 -> 154; 474 -> 1242 collected tests.

The release exists for two measured reasons. A 149-tool catalog costs a client
~40,300 tokens before it has asked for anything, and a drafter searching the way
drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock
index, because those words were simply not in the data.

Discovery and batching
- DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool,
  ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on
  top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised.
- cad_batch runs a step list in one round trip; fields= projects result-heavy
  responses. The `core` profile is gone -- discovery replaced it.

Honesty debt (Tier 0)
- OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and
  rotated geometry reported coordinates that were quietly wrong. backends/ocs.py
  translates at the backend boundary; every tool coordinate is WCS on both
  engines, and a tilted plane refuses rather than answering with a number that
  looks fine.
- drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never
  repaired. analysis_measure_area shoelaced points the caller typed in.
- ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a
  server whose document lock is a single asyncio.Lock.

Architecture
- The 1854-line backend ABC is 19 contract modules; @capability(key, reason=)
  makes optional methods declarative and a gate fails on undeclared holes.
- 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch.
- Wave A: selection filters, boundary tracing, hatch depth, annotation objects,
  measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was
  cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann
  substitute loses 28.2% of the area on a square with one semicircular edge.

Verified against a live AutoCAD 2026, which found four COM defects no amount of
reading would have caught -- AcadPViewport has no ViewCenter, and the line that
set it had been swallowed by a bare except since v1.4.

Benchmark v3 (15 tasks) and what it caught
- v2 scored this server 10/10, which carried no information: every task in it
  exercised something the server was built around. Five tasks were added because
  they can fail, and three did while being written -- a hatch could not be
  measured though the refusal listed HATCH as measurable, the boundary tools and
  the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a
  circle stored as two bulged vertices measured 0.0.
- Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and
  were never asked these five questions; the chart shows those rows `not run`.
- Correctness A/B: 21/24 -> 24/24, zero regressions.

Performance, stated plainly
The published v1.4 perf report was recorded on CPython 3.14 and the new one on
3.11.15, so the apparent 2-6x speedup is the interpreter, not this release.
Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22%
slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores
v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is
documented, and winning it back is on the 1.6 roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:45:20 +03:00