Commit Graph
28 Commits
Author SHA1 Message Date
Umut CANandClaude Opus 5 415de73137 fix(task-2): COM block_explode carries the ATTRIB frame and style onto the TEXT
The BURST path on the live engine read TextString/InsertionPoint/Height/
Rotation/Layer/Invisible and nothing else, so the +Z TEXT that AddText
builds got the ATTRIB's OCS Rotation with no Normal: a mirrored
reference's tag (Normal (0,0,-1), the same premise the ezdxf regression
pins) came back reading theta instead of 180deg-theta, un-mirrored, on
the wrong side of its anchor at theta=0, with ok: True. Style members
(StyleName, ObliqueAngle, ScaleFactor, Backward/UpsideDown, Alignment +
TextAlignmentPoint, Color) were dropped the same way.

_capture_attrib_text_members reads the ActiveX twins of the headless
_ATTRIB_TO_TEXT carry, guarded per member and never written as a guess;
a +Z Normal is not carried and a left-aligned ATTRIB drops its
TextAlignmentPoint (ActiveX refuses it). Writes go Normal/Thickness/
style -> Rotation -> InsertionPoint re-asserted in WCS when the frame
changed -> Alignment -> TextAlignmentPoint -> Layer -> Color -> Visible.

Two fake-surface tests: the mirrored/styled ATTRIB pins every write and
the Normal-before-Rotation and Normal-before-InsertionPoint order; the
bare ATTRIB pins that absent members are skipped. Both fail on the
previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 22:01:08 +03:00
Umut CANandClaude Opus 5 13851a3d20 fix(task-2): block_explode keeps the ATTRIB frame and writes into the owner layout
Two silent wrong-place defects in the BURST path. An ATTRIB is an OCS
entity, so the TEXT that replaces it now carries extrusion/thickness with
insert/align_point; a mirrored reference (ATTRIB reflected to (0,0,-1),
INSERT +Z with yscale=-1) put the tag 50 units across the mirror axis with
a clean audit. Members and TEXTs now go into the INSERT's owner layout
(ezdxf get_layout, COM ObjectIdToObject(OwnerID)) instead of the current
space: a sheet title block exploded while Model was current duplicated
geometry into model space and then died on delete_entity headless, and on
COM landed the tag text in model space with ok: True. A reference nested
in a block definition is refused before any write on both engines.

Five regression tests (mirrored INSERT, sheet-owned INSERT with Model
current, nested INSERT, and the COM owner-block / nested-refusal call
shapes) fail on the previous commit and pass here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:43:08 +03:00
Umut CANandClaude Opus 5 19610b8f05 fix(blocks): block_explode keeps attribute text as TEXT (BURST rule) on both engines
virtual_entities() yields geometry only, so an exploded tagged symbol lost
its tag value silently. Every attached ATTRIB now becomes a TEXT at the
same WCS placement/height/rotation/style/layer (invisible stays
invisible), reported as attribute_texts. COM reads GetAttributes before
Explode, deletes the ATTDEF placeholders Explode returns, AddText per
value, and deletes the reference ActiveX Explode leaves behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:26:27 +03:00
Umut CANandClaude Opus 5 b9510bf377 fix(blocks): entity_create_block_ref refuses an undefined or layout block before writing, on both engines
Track A's task-1 gate covered block_insert only; the sibling tool still
returned a handle for a typo'd name that doc.audit() later deleted, and
both tools accepted *Model_Space (a reference cycle). One gate per engine
(_require_block / _require_block_defined) is now shared by both placing
tools: non-string name -> TypeError, unknown -> ValueError naming it,
layout block -> ValueError, all before add_blockref / InsertBlock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:13:44 +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 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 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 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 e27071ab48 fix(com): except pywintypes.com_error raised NameError without pywin32
Release / test (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
Python evaluates an `except` clause on the way out of every failed call,
not only the ones it is meant to catch. `pywintypes` is bound only when
`sys.platform == "win32"` and the import succeeded, so on any host
without pywin32 -- all of Linux, and Windows installed without the
`[com]` extra -- an ordinary exception leaving `ComBackend._run` hit that
clause and became `NameError: name 'pywintypes' is not defined`, burying
the real error.

Every Linux CI leg failed on exactly this, in
test_ordinary_errors_are_unchanged. The timeout tests survived only
because they matched an earlier `except`; nothing else in the suite sends
a non-timeout exception through `_run`.

Fixed with a module-level `_COM_ERROR` tuple: `(pywintypes.com_error,)`
where the import worked, `()` where it did not. An empty tuple catches
nothing, which is correct when no com_error type exists to be raised -- a
placeholder Exception subclass would be worse than the bug, silently
swallowing unrelated errors.

Reproduced on Windows before fixing by deleting the module attribute,
which resolves the name the same way the absent import does; that is now
a test, alongside one pinning `_COM_ERROR` as a tuple. Verified through
the locked environment CI uses: 1244 passed, 1 xfailed.

README test-count snapshot 1243 -> 1245.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:22:49 +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
Umutcan EdizsalanandCursor 3cbe30df54 feat(server): tool profiles, paper-space layouts + viewports, ISO 286 fits, opt-in COM 3D solids
- TOOL_PROFILE=lean|core|full applied in the lifespan via server-level enable/disable; system_about reports the active profile; opt-in solid_* tools are hidden while ENABLE_3D=false
- layouts: layout_list/create/set_current + viewport_create (scaled model viewports) on both backends; drawing_export_pdf(layout=...); ezdxf uses TILEMODE + active-layout binding, COM switches ActiveLayout and restores it
- ISO 286: engineering/fits.py authored IT4-11 + fundamental-deviation tables (1-500 mm; shafts d..p/h/js, holes D..H/JS) with clear out-of-scope errors; dimension_linear/radius/diameter gained fit= resolved from the measured nominal
- tolerances: DIMTM now signed in deviation/limit modes so double-positive fits (p6 +0.035/+0.022) render correctly; legacy positive-magnitude contract preserved
- solids: solid_box/cylinder/extrude/revolve/boolean native on COM (AddBox/AddCylinder/AddRegion+AddExtrudedSolid/AddRevolvedSolid/Boolean); ezdxf reports the honest solid_3d capability boundary
- capability maps: paper_space native, new viewport_render feature, ENABLE_3D-gated solid_3d
- 60 new tests (profiles, layouts, viewports, layout PDF, fits, gating); version pins in tests now read version.__version__

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:45 +03:00
Umutcan Edizsalan 4a2e9d658f release: prepare v1.3.0 2026-07-17 15:48:43 +03:00
Umutcan EdizsalanandClaude Fable 5 3a0519724b fix(com): create linear dimensions with AddDimRotated (#3)
dimension_linear always failed with '<unknown>.AddDimLinear' on the COM
backend — pywin32's dynamic dispatch error for a method that does not
exist: the AutoCAD ActiveX API has no AddDimLinear. AddDimRotated takes
the same arguments (XLine1Point, XLine2Point, DimLineLocation,
RotationAngle), so the call site only changes the method name.

Adds a mocked regression test asserting AddDimRotated is called with
the exact point/rotation arguments and AddDimLinear never is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:01:53 +03:00
Umutcan Edizsalan 712cf00c5c v1.2.0
Adds production-grade engineering features and tools: 2D GD&T (feature control frames + datum features) via engineering/gdt.py, ISO‑129 dimension tolerance support (engineering/tolerances.py) and an ezdxf/COM plumbing for toleranced dims, a scalar drawing score + invalidity ratio (engineering/scoring.py) wired into drawing_finalize, in-place entity editors (entity_edit_text/entity_edit_geometry), and a friendly drawing_settings facade. Includes server tool wiring, COM/ezdxf backend implementations, datum-consistency critique, many unit tests, README/CLAUDE/CHANGELOG updates, and a version bump to 1.2.0. Also corrects COM backend capability label (entities_2d).
2026-07-07 11:03:08 +03:00
Umutcan EdizsalanandClaude Opus 4.8 dfb21a734a feat(selection): add selection_get — read live viewport pickfirst selection (#1)
New COM-only Entity Query tool that reads AutoCAD's implied ("pickfirst")
selection set and returns the selected entity handles + EntityInfo, so the AI
can scope work (e.g. dimension_auto) to exactly what the user picked in the
viewport instead of the whole drawing. Resolves #1 — the layer-juggling
workaround is no longer needed.

- backends/base.py: abstract selection_get() returning a status dict
- backends/com_backend.py: reads ActiveDocument.PickfirstSelectionSet, surfaces
  the PICKFIRST sysvar, handles the empty-selection case
- backends/ezdxf_backend.py: headless stub (ok=False, empty handles, same shape)
- server.py: @mcp.tool registration (readOnlyHint), SECTION 5 3 -> 4 tools
- README + CHANGELOG + pyproject: 110 -> 111 tools, v1.1.0 -> v1.1.1

Verified live on AutoCAD 2027 (COM): selection_get correctly returned the
user's grip selection across two distinct picks; 318 tests pass, ruff-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:41:36 +03:00
Umutcan Edizsalan 32a68be919 fix: correctness, cross-backend parity & enforced quality gate (audit Sprints 2-4)
Audit-driven fixes across the dual backend, the premium workflow, and the
engineering layer. 318 tests, ruff-clean.

Quality gate (the moat):
- drawing_finalize now runs the premium ISO-128 critique in addition to the
  8-step validator; leftover construction, non-ISO lineweights, untrimmed
  corners, duplicates and dim overlap block the gate (strict_critique fails on
  warnings too).
- dim_overlap critique no longer a no-op (dim reference point + bbox fallback);
  dim/construction layers resolve from the active layer set (iso13567 too).

Stability / correctness:
- view_screenshot & drawing_export_pdf render headless via Agg (no more
  worker-thread SIGSEGV).
- gear tooth profile no longer self-overlaps at high tooth counts; section view
  no longer duplicates bore lines; validator keyway heuristic fixed.
- ezdxf aligned/angular dim API, full-circle polar array duplicate already
  covered by Sprint 1; this bundle adds save-format-from-extension,
  entity_offset side selection, ARC length, properties parity (bounding_box /
  rotation / char_height), mtext rotation.

COM robustness (mocked-COM tested):
- CoUninitialize on teardown; transaction commit/rollback try/finally + UNDO via
  the CMDACTIVE guard; system_run_lisp guard + USERS1 value capture;
  set_variable type coercion; HWND via Application.HWND; BlockInfo.description.

Security:
- HTTP remote-bind guard fires on every launch path (incl. `fastmcp run`);
  StaticTokenVerifier wired into FastMCP; AutoLISP allowlist bypass-vector tests.

Hygiene: linetype-on-demand in _apply_attrs, honest view_zoom returns, snap-on-arc
sweep handling, point_tangent acos clamp, dynamic tool_groups / tool count,
word-boundary SPUR/HELICAL, undo-stack locking.

Deferred: R22 (undo/transaction stack isolation) lands with the I1 refiner (1.2),
which depends on it.
2026-06-19 08:36:58 +03:00
Umutcan Edizsalan 0c7f64ceb6 fix(correctness-bundle): I16 correctness fixes + dead code removal + ruff clean
N1: COM hatch non-associative (AddHatch associativity=False)
N2: ezdxf refuses DWG saves with clear error; server.py derives fmt from extension
R19: dimension_aligned dist→distance; dimension_angular correct add_angular_dim_2l args (base/line1/line2/location)
R9: polar array 360° no-duplicate (divisor=n when fill_angle%360<ε)
R13: entity_offset side_x/side_y honored in ezdxf via cross-product (LINE) and distance-to-center (CIRCLE)
R18: drawing_new bootstrap failure surfaces status="degraded" instead of swallowing silently
Dead code: engineering/section.py, gear.generate_tooth_profile, CommandResult, _ACI_NAMES, set_layer_active, ensure_linetypes, ComBackend._batch_mode instance attr, engineering/__init__ build-seam comment
Ruff: 38 auto-fixed + 5 manual → codebase is now ruff-clean; CI lint is now blocking

184 tests pass.
2026-06-18 11:34:30 +03:00
Umutcan Edizsalan 702af0d6e1 fix: P0 — COM premium crash, lineweight truncation, ISO critique no-op
Three production-blocking defects + two legal/infra gaps from the recon pass.

R1 (CRITICAL): the 8 premium meta-tools (drawing_plan, drawing_critique,
point_from_snap, construction_xline, construction_clear, drawing_apply_iso_layers,
dimension_auto, entity_select_smart) raised NotImplementedError on the COM
backend — i.e. the entire "non-negotiable" premium workflow crashed on the
DEFAULT Windows backend. Lifted all eight to AutoCADBackend as concrete,
backend-agnostic implementations built on the shared abstract primitives +
the identical EntityInfo.properties contract, so both backends inherit them.
The only backend-specific piece left is the new `_create_xline` primitive
(XLINE has no cross-backend entity_create_* equivalent). Removes ~285 lines of
ezdxf-only duplication; the existing meta-tool test suite now covers shared code.

R2 (CRITICAL): layer_create/layer_modify/entity_set_properties used
int(lineweight), truncating every millimetre lineweight (0.50, 0.25, 0.18, 0.13)
to 0 — silently wiping the whole ISO 128 discipline, because engineering/layers.py
passes mm-floats while the MCP boundary passes hundredths-ints. Added
normalize_lineweight() to reconcile both conventions to ezdxf/COM hundredths and
wired it into all six call sites.

R3 (HIGH): _check_iso128 was a permanent no-op (everything was 0 from R2, so the
`lw <= 0: continue` guard skipped all layers). Fixed by R2; additionally exclude
CONSTRUCTION-class layers (intentionally sub-ISO 0.05mm scratch weight) to avoid
a new false positive, and corrected the misleading "normalises to mm" comment.

Also: add MIT LICENSE (referenced by CLAUDE.md but missing — legal gap) and
restore .github/workflows/ci.yml (lint kept non-blocking until the repo-wide
ruff cleanup; the test job is the real gate).

Verified: 182 passed / 2 xfailed; functional check confirms lineweights now
store as hundredths (GEOMETRY=50) and the ISO critique correctly flags a 0.40mm
layer while passing a clean ISO drawing.
2026-06-18 10:02:09 +03:00
U-C4N 9258da429f Add AutoCAD MCP premium skill and backend APIs
Introduce the autocad-mcp-premium skill with workflow docs, examples, and references (.claude/skills/autocad-mcp-premium/*). Add PlanSpec/critique plumbing and engineering helpers (engineering/plan_spec.py, engineering/critique.py) plus tests and a smoke script. Extend backend API (backends/base.py) with corner operations and premium meta-tools (trim/extend/fillet/chamfer, drawing_plan, drawing_critique, point_from_snap, construction_xline/clear, drawing_apply_iso_layers, dimension_auto, entity_select_smart) and implement COM backend corner ops (backends/com_backend.py) with placeholders for meta-tools. Update CLAUDE.md to document the premium drawing rules and standard workflow. Misc: add examples, references, and tests for premium features, and minor server/backend adjustments.
2026-05-15 19:17:14 +03:00
U-C4N 88a9005a6a Add deterministic engineering CAD layer (gear, keyway, titleblock, validator)
Introduces an engineering/ package that lets MCP clients produce production-
quality technical drawings without LLM-improvised geometry. Adds 7 new MCP
tools, an 8-step pre-completion validator, and an automatic linetype + layer
bootstrap on drawing_new so CENTER/HIDDEN/PHANTOM and standard mechanical
layers (GEOMETRY, DIM, HATCH, TEXT, TITLEBLOCK, ...) are always available.

- engineering/gear.py: pure-Python involute math, 40+ points/flank, helical
  + spur front views, deterministic section A-A (no helix lines in section).
- engineering/keyway.py: DIN 6885 keyway table + parametric keyed bore /
  side-section drawers.
- engineering/titleblock.py: ISO 7200 A3 (420x297 mm) title block; title
  text is written verbatim to the title cell.
- engineering/validator.py: DrawingValidator with 8 checks (real DIMENSION
  entities, no hidden orphans, linetypes loaded, bore + keyway in both
  views when expected, title consistency e.g. forbid 'HELICAL SPUR GEAR'
  when helix_angle truthy, saved-to-disk + file-on-disk, screenshot
  exported, dwg path returned).
- backends/ezdxf_backend.py: AutoCAD-only linetype fallback (HIDDEN,
  BORDER families) so ezdxf doc gets correct patterns when linetype_load
  is called for names ezdxf.tools.standards does not ship.
- backends/base.py: concrete helpers set_layer_active / ensure_linetypes.
- server.py: Section 12 with 7 engineering tools, drawing_new(bootstrap=
  True), and an ENGINEERING WORKFLOW block in the MCP instructions string.
- 30 new tests covering gear math, DIN6885 lookup, ISO A3 layout, title
  verbatim, and every validator check. Tool count 89 -> 96.
2026-04-28 12:24:08 +03:00
U-C4N 7b121ab416 Initial commit 2026-04-27 12:20:57 +03:00