Commit Graph
201 Commits
Author SHA1 Message Date
Umut CANandClaude Opus 5 bd73b3a854 fix(task-6): a reachable ISO 286 route, a tolerance schema that refuses, a reference dimension
Three review findings, all root causes in `engineering/mech/dimension.py`.

1. The ISO 286 fit path was unreachable from the module's own pipeline.
   `resolve_tolerance` resolved `intent.fit` correctly, but nothing produced a
   DimIntent carrying one: no feature and no segment has a fit, and
   `dimension_intents` constructed every intent without `fit=`. Only a
   hand-built DimIntent - which is what the tests used - reached the code, so
   the spec's `mech_iso286_on_dimension` and the `mech_assembly` benchmark's
   "ISO 286 fits on the bearing seats" had no route.

   `dimension_intents(..., fits={key: code})` now stamps the code onto the
   intent its key names, and the part's own diameters became addressable one
   at a time through the new `segment_key(i, bore=...)` -> "segment[1]",
   "segment[1].bore"; a feature's stay addressable by id. A key that names
   nothing is refused with the names that exist, a malformed code is refused
   where it is attached rather than at draw time, a fit on a reference
   dimension or on one that already carries a tol is refused, and a fit on a
   hole whose diameter moved into the hole table (which has no tolerance
   column) is refused by name instead of vanishing. Task 7's planned
   `dimension_part(..., fits=None)` passes straight through - the argument is
   additive and keyword-only, so every call written against the pinned
   signature keeps working.

   The fit also now reaches the *text*: `intent_text` expands the `<>`
   placeholder and appends the code, so `text_rects` measures the string that
   is actually drawn and a hole that already names itself ("⌀6") keeps its own
   text and gains the code.

2. An explicit tolerance was silently discarded when `tol` omitted "mode".
   `mode = tol.get("mode", "none")` and `build_dim_override(0.05, 0.05,
   "none", None)` returns `({}, None)` without raising, so a ±0.05 resolved,
   echoed its own numbers back in the payload, and drew a dimension with no
   DIMTOL/DIMTP/DIMTM at all. `DimIntent.tol` now has a schema - {"mode",
   "upper", "lower"} and nothing else - validated by `_explicit_tol`: an
   unknown key, a missing "mode", a non-dict, and deviations under a mode that
   writes none are each refused by name. The mutual-exclusion guard reads
   `tol is not None` rather than truthiness, so `tol={}` is refused too.

3. The ISO 129-1 "no measurement twice" test was vacuous and the property was
   false. The old test built the key `_dedupe` uniquifies as the last step of
   `dimension_intents`, so it could not fail; meanwhile `style="chain"` emitted
   20, 40, 15 *and* 75 as four plain DIMENSIONs. `redundant_linear` now finds
   the span that shorter ones tile end to end on the same axis and level, and
   `dimension_intents` marks it as an auxiliary (reference) dimension - the new
   `REFERENCE_FORMAT` "(<>)", which renders "(75)". The plan's own test still
   holds (the 75 is present); it is no longer a fourth plain dimension. The
   error is one-sided on purpose: a tiling that is found is real, a tiling the
   greedy walk misses leaves the dimension plain, which is the direction the
   repository's critique rule wants. Baseline and ordinate parenthesise
   nothing, and two horizontal chains at different heights are not each other's
   pieces.

Tests: the vacuous gate is replaced by four that fail against the pre-fix code
- `redundant_linear` on the shaft's plain intents returns (3,) there and ()
here; the parametrised "a tolerance is either refused or reaches the dimension
variables" gate resolves the mode-less shape to an empty DIMVAR override
there; and `fits=` is a TypeError there. `_dedupe` is now exercised through a
duplicate fed in via the view's own dims, which the collector does not
generate itself. 46 tests in tests/test_mech_dimension.py (was 23).

Live AutoCAD: no finding was measured live and no new ActiveX call was
written - this module is pure Python with no backend, no async and no DXF. A
DIMTOL probe against the running seat was attempted twice and
`GetActiveObject("AutoCAD.Application")` blocked past the ~90 s contention
window both times, so the live engine was unavailable; no scratch document was
created (the probe never got the application object) and none was left open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 23:04:25 +03:00
Umut CANandClaude Opus 5 01213fc5f5 feat(task-6): dimension intents, ISO 286 fits and an overlap-free layout
engineering/mech/dimension.py collects what the features asked to have
dimensioned, adds the part's own sizes (chain, baseline or ordinate for the
axial lengths, a diameter per segment and per bore, the two overall sizes of a
plate), and removes the duplicates - ISO 129-1 says each dimension appears once,
and a test asserts no measurement is placed twice.

Fits and tolerances are resolved through the modules that already own them:
engineering.fits for an ISO 286 code and engineering.tolerances for an explicit
ISO 129 tolerance, including build_dim_override's sign convention (the lower
deviation is passed as the magnitude of the minus side, exactly as
server.py::_fit_to_tolerances does). A H7 bore at 40 mm resolves to +0.025/0 -
the correctness check the spec names. A fit together with an explicit tolerance,
a fit outside the authored ISO 286 range, and a fit on an intent with no
measurable nominal are each refused by name rather than guessed.

The layout is the part that had to be measured rather than asserted: every
intent's text is turned into a rectangle by the same helper the test uses, and
an intent is placed on the lowest level whose rectangle is free, with the level
pitch clamped to clear the text whatever the caller passed. The test then
asserts the placed rectangles are pairwise disjoint - including on a crowded
plate and with a deliberately too-small step. That is the same property the
repository's dim_overlap critique focus checks on the finished drawing.

The plan's layout clamped that pitch to the text HEIGHT on every axis, which is
only the stacking direction for the row of dimensions under the view. Beside the
view the levels march in x, so the extent they have to clear is the text WIDTH:
measured here, three diameter labels are 9.45 mm wide (9.45, 5.6) at a 8.0 mm
pitch, so a shaft's three segment diameters - all on the y = 0 centreline, hence
all on different levels - overlapped by 1.45 mm. The pitch and the within-level
span are now both taken per axis from the same _text_size helper text_rects
uses, which makes the disjointness a property of the algorithm rather than of
the label lengths that happened to be in the test.

Over hole_table_threshold holes the per-hole diameters leave the view and become
a table instead, grouped by diameter, thread, counterbore and depth, one tag per
group and the quantity on every row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 22:27:09 +03:00
Umut CANandClaude Opus 5 978c1075ad fix(task-5): cut faces on the real chord, cross sections that see features, a section with no dashed cut edge
Three defects, all of them the same mistake in three places: the cut face was
built from something other than the geometry it claims to show.

1. A prismatic cut face removed a feature by its BOUNDING BOX. `Hole.removal_box`
   is a square, so the gap punched in the cut face was the full diameter
   wherever the plane passed. On the plan's 80x50x8 plate with a Ho10 hole at
   (40, 25): a plane at y=29.9 grazes the hole (true chord 1.990) and the
   section still showed an 8 mm gap through solid metal - 560.0 mm2 hatched
   against a true 624.08, a 64 mm2 error in the hatched area AND a wrong drawn
   outline. `Slot` had the same obround-as-box. Features now answer
   `cut_spans(part, p1, unit)` with their own exact chord: `_circle_chord` for a
   hole, the union of two circles and a rotated rectangle for a slot, the
   polygon's own crossings for a pocket, and an exact slab clip of
   `removal_box` as the default. `_box_span` (the corner projection, which is
   the box's shadow and not a chord at all) is gone.

2. A transverse revolved section ignored every feature and the honesty rule
   never fired. A cross section through a DIN 6885-1 keyway came out as a plain
   hatched disc with `omitted == ()` - the commonest mechanical cross section
   there is, hatched straight over the 12x5 keyway. The face is now built by
   `_transverse_face`: its radii come from `radial_profile` (so a groove, an
   undercut, a bore or an ISO 6410 internal thread is in the face already,
   which the raw segment diameters could not report), a non-axisymmetric
   feature is asked for its own `cross_section` notch record, and one the plane
   really passes through with no such record is listed in `omitted` with its
   reason rather than hatched over. `Keyway._notch` is the one calculation the
   end view and the cut face both come out of.

3. A section drew its own cut boundary as a HIDDEN (dashed) line, because the
   longitudinal branch reused `silhouette_prims` and the silhouette draws the
   bore dashed - correct for an un-cut view, wrong for a section, where that
   line IS the hatch loop's boundary. ISO 128-50 bounds a surface lying in the
   cutting plane with a continuous wide line and ISO 128-3 leaves hidden detail
   out of a sectional view; via ROLE_LAYER['hidden'] this would also have put
   the cut boundary on the HIDDEN layer in Task 7. New `section_profile_prims`
   emits the cut outline all-continuous, and `half=True` keeps the dashed bore
   on the -Y half only, which is the half a half section does not cut.

And the claim the last commit made without earning it: the hatched area and the
drawn outline are now literally one calculation. The longitudinal loop is the
*emitted* outline flattened (`_flatten_path`), so a chamfer or fillet that
substitutes a silhouette corner is cut out of the hatch exactly where it is
drawn - a 2 mm 45 deg chamfer takes its 2 mm2 off the 1000 mm2 half face
instead of being hatched over.

Eleven new tests, every one of them red on the parent commit: the hole chord at
0/4/4.9 mm off centre, a tangent plane leaving the face whole, the 45 deg slot,
the keyway cross section against its own analytic area, the feature a cross
section cannot project, the feature the plane misses, the groove read off the
profile, the continuous cut boundary, the half section's dashed half, and the
chamfered cut face.

No new ActiveX member is called: all three fixes are pure geometry in the
backend-free `engineering/mech` package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 22:16:47 +03:00
Umut CANandClaude Opus 5 72bd17d024 feat(task-5): sections, cut faces, material hatch and detail views
engineering/mech/views.py grows the two remaining view kinds. A section's cut
faces come out of the same radial profile the silhouette does, so the hatched
area and the drawn outline are one calculation and cannot disagree: a solid
shaft's half-section loop measures exactly the sum of its segment rectangles,
and a bore closes the loop on itself rather than being hatched over.

ISO 6410's section convention falls out of the model rather than being special-
cased: an internal thread's radial_edit already raises the bore to the major
diameter, so the hatch stops there. ISO 128-3's rib rule is a flag on the
feature - a pocket marked no_section_hatch is drawn and left unhatched, while
the rest of the cut face is hatched with the part's ISO 128-50 material.

Four styles, each either implemented or refused by name with the alternative
rather than approximated: full and half on a longitudinal plane through a
revolved axis; full and revolved on a transverse plane (the annulus, flattened
at 2 degrees - 0.015% of the radius, stated in CIRCLE_SEGMENTS rather than
hidden); full and offset on a prismatic part, where through features split the
cut face and pockets reduce it to the remaining thickness. A plane that misses
the part, or that grazes an outline vertex so its crossings cannot be paired, is
refused instead of producing a guessed cut face.

Details scale a circular region of a parent view, emit the ISO 128-34 circle and
letter for the parent and the "A (5:1)" label for the detail itself, and report
every feature that falls outside the circle in omitted - the honesty rule again.

Three test assertions were corrected to the measured truth rather than the
engine bent to them, each verified by running the code:

* the ISO 6410 test asked for the whole cut-face loop's minimum to be the M20
  major (r=10). It is not, and must not be: over the 5 mm that is drilled but
  not threaded the loop closes on the real 16 mm drill (r=8). The loop is
  ((0,10),(0,25),(25,25),(30,25),(30,8),(30,8),(25,8),(25,10),(0,10)). The test
  now pins both spans, which is the convention it meant to assert.
* the transverse-annulus areas were pinned at rel=2e-4. An inscribed regular
  180-gon is exactly 1 - (n/2pi) sin(2pi/n) = 2.0307e-4 under the circle, so
  2e-4 rejects the very flattening CIRCLE_SEGMENTS declares. The tolerance is
  now 2.1e-4 and the test computes the deficit and pins it, so the number is
  derived rather than guessed. CIRCLE_SEGMENTS' docstring now states both
  figures: 0.015% radial (the 2 degree chord's sagitta) and 0.0203% of area.
* tests/test_mech_views.py pinned VIEW_KINDS == ("front","side","top"), which
  this task deliberately grows. It now pins the three orthographic kinds as the
  first three and the full five-kind tuple.

_is_longitudinal takes only the plane; the plan's signature also took the part
and never read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 21:40:29 +03:00
Umut CANandClaude Opus 5 b2f858212f fix(task-4): the end-view occlusion rule, the double-drawn groove, the left view
Three defects in the orthographic view engine, all one missing idea: the engine
had no visibility rule and no single owner for the silhouette.

1. `_end_circles` inverted the hidden-line rule. Walking the bands from the
   viewing end it drew a circle only when its radius EXCEEDED everything nearer,
   and roled every such circle `hidden` unless it was the first band - so the one
   edge that CANNOT be occluded (a step face larger than the material in front of
   it) shipped dashed on HIDDEN, and the circle that really is covered was not
   drawn at all. `_front_max` is now the rule: an edge of radius r at station x is
   continuous when r is larger than every outer radius between x and the observer,
   dashed when it is not, and it is always drawn. Bores follow the mirror image -
   you see down a bore only while it stays at least as wide. Each distinct radius
   is emitted once, because two coincident circles are a duplicate entity.
   The same rule now roles a feature's axis-centred end-view circle, whose axial
   station comes from `removal_box`; a feature that reports none keeps its own
   verdict rather than having one guessed.

   Ground truth by ray cast along the axis of the same solid of revolution
   (segments 20x25, 40x30, 15x20; observer at +X, probe just inside each edge):
     r=10   at x=75 -> first surface x=75 -> VISIBLE  (was visible, kept)
     r=15   at x=60 -> first surface x=60 -> VISIBLE  (was HIDDEN, fixed)
     r=12.5 at x=20 -> first surface x=60 -> HIDDEN   (was not drawn, fixed)

2. Every axisymmetric groove was drawn twice - once as the notch
   `radial_profile` cuts into the silhouette, once by the feature's own
   `prims()`, which `_feature_layer` appended unconditionally. Six exactly
   coincident LINEs per grooved shaft, which is precisely what
   `critique.py::_check_duplicate_entities` matches, so premium rule 7 could
   never be met on a part carrying a retaining or O-ring groove. `_profile_owned`
   now drops a feature primitive that the body already emitted, and drops a
   revolved front view's `visible` silhouette line from any feature that edits
   the radial profile: the spec's rule is that grooves cut a notch in the
   silhouette, and the notch belongs to the one calculation. Suppression is
   "the profile already drew this", not "this feature edits the profile", so a
   blind bore keeps the bottom line the profile cannot express, and an internal
   thread keeps its ISO 6410 limit line.
   A groove straddling a step was worse than duplicated: the feature reads one
   surface radius for a span that has two, so it drew a groove floor through
   solid material and left the +Y path with four odd-degree nodes. It now has
   two - its two ends on the axis.

3. `build_view` validated `side` and put it in ctx, but the prismatic branch
   ignored it: `side="left"` returned the right-hand view, with every feature on
   the wrong side of the sheet and a symmetric outline rectangle to hide it. An
   observer at -X sees screen-right = -Y, so the feature layer is now mirrored
   about the outline's mid-Y. The top view is always seen from above and does
   not move with `side`; that is now said in the docstring and pinned by a test.

Tests: the two that pinned the inverted rule are rewritten around the ray cast,
and one of them contradicted its own name. Seven tests added, and the prismatic
side-view test's hole moved off the y=25 mirror axis, where it could not tell a
left view from a right one. Nine of them fail against the previous `views.py`
and pass against this one.

Live AutoCAD was unavailable for a SOLPROF cross-check: the running AutoCAD 2026
(pid 40068) reports Responding=False and `GetActiveObject("AutoCAD.Application")`
never returned inside 100 s, so no scratch document was created and none is left
open. No new ActiveX member is called by this change - views.py is pure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 20:38:57 +03:00
Umut CANandClaude Opus 5 48eb057273 feat(task-4): the orthographic view engine - silhouette, hidden lines, layout
engineering/mech/views.py projects a part model onto a view plane and returns
primitive descriptions, so a test asserts geometry rather than a screenshot.

The one calculation everything comes out of for a revolved part is the radial
profile: the part cut into bands of constant bore and constant (or linearly
tapering) outside, with every feature's radial_edit applied. The front view is
that profile walked as a path and mirrored about the axis; the section cut face
of the next task is the same profile walked as a closed loop, which is what
stops the outline and the hatched area ever disagreeing. Chamfers and fillets do
not draw over the silhouette - they substitute their corner in it, so the
overshoot the critique focus looks for cannot be produced by construction, and a
corner edit that finds no corner is reported rather than dropped.

A band's radii are read off the segment the band's MIDPOINT lies in and the
taper is evaluated at the band's own two ends. Sampling a hair inside the band
to dodge the segment-boundary ambiguity - the way the plan's draft did - writes
the taper slope times that inset into every coordinate the silhouette is built
from: a 30 -> 10 mm cone over 20 mm then reports 14.99999 for a radius that is
exactly 15, and nothing downstream can tell that it is wrong.

The end view applies a real hidden-line rule: walking the bands from the viewing
end, a diameter larger than everything nearer than it is drawn hidden, a bore
behind material is hidden, and the near end's own circles are visible. Looking
from the left reverses it. A revolved part's top view is refused, because it
repeats the front.

Prismatic parts get the outline in true shape with corner fillets and chamfers
substituted in, and side/top thickness rectangles - the case where a hand-drawing
model reliably forgets the hidden line per through feature, which here comes out
of the feature itself.

The honesty rule of spec section 6 is enforced in one place: a feature that emits
no primitives and edits neither the radial profile nor a corner is listed in the
view's omitted tuple with its own reason. Nothing is silently dropped.

layout_origin places views in first-angle positions by default (the view from the
right goes to the left, the view from above goes below) and flips both for third
angle. It takes one additive keyword beyond the pinned signature, child, because
placing a view to the left depends on that view's own width; without it the child
is assumed the parent's size and every pinned call still works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:56:04 +03:00
Umut CANandClaude Opus 5 457c53644f fix(task-3): keyway end-view arc, the machined face, and the chamfer's segment bound
Three defects in the feature catalogue, all faithfully transcribed from the
plan text and all wrong in shipped code.

1. Keyway.prims(view="side") emitted the arc the keyway REMOVES. `Arc` is CCW
   start -> end, and the emitted (right wall -> left wall) is the ~31 degree
   cap over the slot opening, not the ~329 degrees of shaft that survives it.
   Measured live on AutoCAD 2026 (Documents.Add scratch doc, closed in a
   finally): ModelSpace.AddArc(center, radius, start_rad, end_rad) -- angles in
   RADIANS, CCW start -> end -- gives ArcLength 8.098 mm for the emitted
   74.534 -> 105.466 deg and 86.150 mm for the reversed 105.466 -> 74.534 deg,
   against a 94.248 mm full circle at r = 15; StartPoint/EndPoint of the
   emitted arc are exactly the notch walls at x = +/-4. Swapped, so the arc now
   runs left wall -> right wall the long way round, and the side view's bbox
   goes from (-4, 11, 4, 15) to (-15, -15, 15, 14.457).

2. Hole and Pocket cut the plate from OPPOSITE faces in the same view frame:
   Hole ran z = 0 -> depth, Pocket z = thickness -> thickness - depth, and
   neither takes a face parameter. A plate carrying both showed the pocket
   milled from one side and the blind hole drilled from the other, silently --
   omission() returns None for both, so nothing reported it. The frame comment
   now names the machined face (v = thickness, the face the front view looks
   at), and Hole drills from it: the blind floor, the counterbore step and the
   through bore all hang down from there, as Pocket already did.

3. Chamfer.validate bounded the axial leg against the whole part, never against
   the segment the chamfer sits on. A shallow lead-in passed validation and was
   drawn straight through a shoulder at a radius that segment does not have
   (size 30 at 10 deg on a 15 mm end segment: radial 5.29 < 10 so the radial
   guard was happy, yet `before` landed at x = 45, two segments away). New
   helper _anchor_segment names the segment a corner actually cuts into -- at a
   step, the adjacent one carrying the larger radius -- and the leg is bounded
   by its length, refused by name with the step it would have crossed.

Tests: the keyway test asserted only primitive counts and two x coordinates, so
it passed with the arc reversed; it now pins the sweep, the endpoints and the
bbox. The hole test exercised only a through hole, which is symmetric; added a
blind-hole-and-pocket test that pins both to the same face, and a counterbore
test. Added three chamfer tests (two refusals, one that must still be
accepted). Each fix was mutation-checked: reverting it fails its test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:48:00 +03:00
Umut CANandClaude Opus 5 ac6c4075ba feat(task-3): the feature catalogue and its standards tables
engineering/mech/features.py is the nineteen typed features of spec section 5,
each answering the same four questions - is my placement legal, what do I
contribute to this view, to a cut face, and what dimension do I want - plus the
five structural hooks the view engine needs: removal_box, radial_edit,
corner_edit, outline_edit and omission. Pure arithmetic; a test asserts emitted
primitive tuples, not a screenshot.

Nothing restates a table the repository already owns: Keyway takes its width
and depth from engineering.keyway (DIN 6885-1) and is refused by name outside
that table's bore range; GearTeeth takes its outline from engineering.gear's
involute generator and is refused when its tip diameter disagrees with the
segment it sits on.

standards/threads.py transcribes ISO 261 Table 1 and Table 2 - first-choice
M1.6-M64 plus second-choice M14/M18/M22/M27/M33/M39/M45/M52/M60 - and computes
ISO 724's basic minor and pitch diameters from ISO 68-1's own formula, pinned
against the published values (M20x2.5 -> 17.294 / 18.376). The remaining
second-choice diameters, every third-choice diameter and fine pitches below M8
are not transcribed and are refused by name, never interpolated. The drawing
rules are ISO 6410: minor at 0.8 x major, a thread-length limit line across the
major, a three-quarter circle in the end view, and an internal thread whose
section hatch runs to the major diameter.

standards/{undercuts,rings,centres,orings}.py ship DIN 509, DIN 471/472,
DIN 332-1 and ISO 3601-2 with their SOURCE, their row-key contract and an
**empty** row set. That is deliberate and is the track's table rule taken at its
word: a row that could not be verified against the standard is not shipped,
because a wrong relief depth or groove diameter is a wrong workshop drawing
while a narrow table is merely narrow. Every lookup is refused by name and says
what to transcribe and where; the features accept explicit dimensions so the
geometry is fully exercised, and DIN 332 form R is refused outright rather than
approximated by the straight 60 degree cone of forms A and B. Filling any of
these tables later is a data edit with no code change, and a shape test already
fails any row that lands without a column the drawing code reads.

Three deviations from the plan text, each measured rather than assumed
(the merge agent should keep exactly one copy of the first):

1. standards/__init__.py (Task 1, on main) refused a registration with no rows
   ("registered no rows"), which would have made the four not-transcribed
   tables impossible to register at all - and registering them is what keeps a
   later transcription a data edit. register() now refuses a rows argument that
   is not a dict and accepts an empty one; two tests in tests/test_mech_standards.py
   pin both halves. No existing test asserted the old refusal.

2. ORingGroove's housing kind is carried by `housing`, not `kind`. A dataclass
   field named `kind` overwrites the discriminator _Base.to_dict() writes:
   measured, Probe(id='og').to_dict() == {'kind': 'radial', 'id': 'og'}, so
   feature_from_dict would then refuse its own payload as an unknown feature
   kind and no part carrying an O-ring groove could round-trip through XDATA.
   A new test pins the round trip.

3. The plan's end-chamfer test asserted after == (75.0, 10.0) - the corner
   itself - and before == (72.0, 8.268), a point on neither surface. The real
   edit is before (72.0, 10.0) on the cylinder and after (75.0, 8.268) on the
   end face, consistent with the 45-degree start chamfer the same file pins.
   The test was corrected to the measured geometry; the behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:12:00 +03:00
Umut CANandClaude Opus 5 04d269318a feat(task-2): the mechanical part model - RevolvedPart, PrismaticPart, build_part
engineering/mech/part.py is the model the whole track draws from: a revolved
stack of segments along +X, or a closed prismatic outline plus a thickness, each
carrying a list of typed features. Pure arithmetic - no backend, no async, no
DXF - which is what lets the view engine be tested without a drawing.

Every refusal fires before anything is drawn and names the offending path the
way pid_from_spec does: a non-finite or non-positive number as
segments[2].d_outer, a bore that is not smaller than its outside (a bore that
changes diameter is two segments), an outline with fewer than three distinct
vertices, an outline that crosses itself (engineering.measure.is_self_intersecting
- the shoelace cancels crossed lobes rather than failing, so a bowtie would
otherwise measure 0.0), an unknown material, an unknown segment key, a duplicate
feature id.

Two gates are the reason this is a model rather than a dict: a feature whose
placement is off the part is refused by its own name and by the range it missed,
and two features that would remove the same material are refused with both names
and the overlapping box. Neither is clamped.

part_to_dict round-trips through build_part, which is what lets mech_part_draw
write the model onto the drawing as ACADMCP_MECH XDATA and mech_view_add read it
back months later without the caller re-describing the part.

Two fixes to the plan's own code and test list, both recorded in deviations:
_features_from imported engineering.mech.features unconditionally, so a
featureless part - every fixture in this module, and both round-trips - died on
ModuleNotFoundError until Task 3 lands; the import now happens only when there
is a feature dict to build. And the plan's test file omitted
test_a_duplicate_feature_id_is_refused while its own gate deselects it by name,
so the duplicate-id refusal the commit message claims went unpinned; the test is
written here and Task 3 turns it green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 18:52:39 +03:00
Umut CANandClaude Opus 5 2bb8044144 feat(mech): shared foundation - primitives, XDATA codec, standards registry, ISO 128-50 materials
engineering/mech/ opens with the four modules every track-B/G branch imports,
so the four worktrees can be cut from this commit and none of them has to
invent a name the others will conflict with.

- primitives.py: the typed descriptions the pure modules emit (Line, Arc,
  Circle, Poly, HatchArea, Text, DimIntent) with bbox / translate / rotate /
  scale, and ROLE_LAYER - the one place an ISO 128 line role becomes a layer
  name, so no module hardcodes a layer. The arc bbox sweeps its quadrants: a
  0-180 arc reports y=+r, not its endpoints, which is what keeps the view
  layout from stacking two views on top of each other.
- xdata.py: the ACADMCP_MECH codec in Track A's shape - a 1001 application
  marker, 1000 chunks of at most 255 ASCII characters, the 16 KB per-entity
  ceiling enforced before anything is written, and a foreign application, a
  corrupt chunk stream, a wrong version or an unknown kind refused with
  ValueError instead of decoded into something plausible.
- standards/__init__.py: the table registry. A size outside the coverage is
  refused against the coverage; a size inside it that is not a listed row is
  refused against the rows. Nothing is interpolated, an ambiguous variant is
  never resolved by taking the first row, and a registration whose SOURCE does
  not name the table it was transcribed from is refused.
- standards/materials.py: the section-hatch map for nine materials. ISO
  128-50 standardises only the general hatching (narrow continuous lines at 45
  degrees) - that is acadiso.pat ANSI31 and what engineering/gear.py already
  draws for steel. The distinguishing symbols are ASME Y14.2M's section lining
  as shipped in AutoCAD 2026's acadiso.pat, read off the installed file; cast
  iron's departure from ASME and the absence of any mechanical symbol for
  concrete and wood are stated in the rows themselves. Angles are 0.0 because
  the patterns already carry 45 degrees, and the two architectural patterns'
  scales are derived from the ANSI31 line spacing and recomputed by the test
  rather than chosen.

No tool is registered here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 17:04:59 +03:00
Umut CANandClaude Opus 5 a8482a5f0d docs(spec): align tracks B+G with the written plan
The five gaps the plan's reconciliation found, each resolved in the spec:
part placement lives in draw_part(at=, rotation=) so the dataclasses carry no
origin; engineering/sheet/refs.py is gone with the contract correction;
image_attach takes no clip and titleblock_apply no position (the ISO 7200
corner is fixed by the standard) — both recorded as out of 1.6; balloon_add
places one balloon per call against the item numbers bom_extract already
assigned.

Records the four feature tables that ship empty (DIN 509, DIN 471/472,
DIN 332-1, ISO 3601-2): no row could be verified against the standard here, so
the structure, the row-key contract and the refusal ship without rows, and the
features draw from explicit dimensions until the tables are transcribed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 16:52:23 +03:00
Umut CANandClaude Opus 5 5497dc01d9 docs(plan): tracks B+G — 16 TDD tasks in five groups
Assembles the skeleton and the five drafted parts into one plan, Task 1
through Task 16, each a TDD task with its failing test, its implementation
and its gate.

Groups:
- (main) Task 1 — primitives, the ACADMCP_MECH codec, the standards registry
  and the ISO 128-50 material map; the shared foundation the four branches
  all consume.
- M (Tasks 2-7) — the part model, the feature catalogue with its DIN/ISO
  tables, the view engine (orthographic, then sections and details),
  dimensioning, and SECTION 21's six tools.
- S (Tasks 8-9) — the ISO 4014/4017/4032/7089/4762 and ISO 15 tables, and
  SECTION 22's three tools.
- A (Tasks 10-11) — ISO 21920-1 surface texture, ISO 2553 welds, centre
  marks, the ISO 128-40 cutting-plane line and the material hatch:
  SECTION 23's five tools.
- G (Tasks 12-14) — ISO 5457 frames and ISO 7200 title blocks A4-A0,
  revisions, the ISO 7573 parts list and ISO 6433 balloons, xrefs, images,
  data extract and DWG: SECTION 24's eleven tools and the four contract
  members.
- F (Tasks 15-16) — the six mech_* critique focuses, the mech pack, lean,
  aliases and golden queries; then the v5 benchmark, the correctness checks,
  the live COM smoke and the docs.

Interface pass over the assembled file: View.dims is tuple[DimIntent, ...] as
the skeleton pins it; every XDATA writer calls Task 1's to_values rather than
re-deriving the chunk split from encode; stdparts' five standard features are
STD_FEATURE_KINDS so they do not collide with features.FEATURE_KINDS; the
revision block carries its own ACADMCP_SHEET_REV application instead of a
fourth ACADMCP_MECH kind that the pinned KINDS would refuse; and Task 16
states 39 correctness checks, which is what it registers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 16:50:33 +03:00
Umut CANandClaude Opus 5 c97be8fc40 docs(plan): tracks B+G skeleton — 16 tasks in five groups, interfaces pinned
The header, global constraints, file structure, the interface block every task
builds against, the group/ownership table and the merge notes. Task sections
are drafted next.

Also corrects the spec's architecture section: the engineering tools compose
the generic contract members the way gear/keyway/titleblock already do, so only
four operations (xref_attach, xref_manage, image_attach, drawing_export_dwg)
need contract members and an implementation per engine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 14:56:37 +03:00
Umut CANandClaude Opus 5 5ff66b641f docs: tracks B+G (mechanical 2D and the sheet standard) design spec
One part model — a revolved or prismatic profile plus typed features — and one
view engine that projects it into front/side/section/detail views with hidden
lines, cut faces and dimensions, instead of a generator per part shape. Around
it: the standard-parts catalogue as code inserted as blocks with attributes,
the ISO annotation symbols, and the sheet standard (ISO 5457 frames A4-A0,
ISO 7200 title blocks, revisions, ISO 7573 parts list, balloons, xrefs, DWG).

25 tools in four sections, new pack `mech`, three capability keys, six mech_*
critique focuses, benchmark task `mech_assembly` and six correctness checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 14:45:24 +03:00
Umut CANandClaude Opus 5 39e6e2f9e4 docs(settings): track E shipped — CLAUDE.md sections 18-20, README, changelog with the live COM smoke record
CLAUDE.md gains items 18-20 (styles, page setup & templates, environment),
the grown drawing_settings facade on item 11, lean 55 / 52 and
TOOL_PACKS=all|core,pid,settings, and three conventions: layer states are
ours (ACADMCP_LAYERSTATES XRECORD, mode "xrecord"), page-setup evidence is
the PDF's own /MediaBox, nothing interprets coordinates in UCS.

README token table re-measured on this tree with benchmarks/token_suite.py
(ratio/v1 estimates): full 199 tools / 59,315 tokens, core,settings 190 /
55,593, core 168 / 48,825, lean 55 / 16,360, search 2 / 356; corpus
re-derived from discovery.aliases: 186 AutoCAD command names, 933 synonym
phrases, 204 tools. Styles & standards row, page setup & templates row,
final environment wording, and "Known limitations of the settings track".

CHANGELOG Unreleased: the track E intro, Added (styles, page setup &
templates, environment, drawing_settings growth, benchmark evidence),
Fixed (the Track A hardening list), Changed (three groups, packs, measured
idle cost, release snapshot 204 · 8 · 5 · 3284). Spec status: implemented.

Where the plan's text and the tree disagreed, the tree wins:
mleaderstyle_create is implemented on both engines through the
ACAD_MLEADERSTYLE dictionary and no `mleaderstyle` capability key exists
(spec §4, capability maps), so the docs say so instead of calling it
ezdxf-only; the Task 7 fix (tolerant ACADMCP_PID payloads, crossings
tested against the arc) joins the hardening list the plan left it out of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:38:14 +03:00
Umut CANandClaude Opus 5 18c7c69da6 fix(task-25): page_setup_truth and settings_pdf_mediabox_a3 move the sheet to ANSI B before proving A3
A fresh Layout1 is already ISO A3 landscape (ezdxf R2018 default: 420 x 297,
plot_rotation 0), so an A3-only /MediaBox read-back passed with
page_setup_apply stubbed to a no-op: the A/B gate row and the v4 matrix task
could not fail on the setter. Both now apply ANSI B (432 x 279, a size no
fresh document has; spec section 9 names it) and read that PDF first, then
apply ISO A3, read its PDF, and require the setter's changed.size_mm to have
moved from B to A3. The stub fails at the first PDF.

The moved-sheet comparison is +/-0.5 mm, the spec's tolerance, not exact:
measured live on AutoCAD 2026 (scratch Documents.Add(), closed unsaved), the
COM engine reports ANSI B as 431.8 x 279.4 and the DWG To PDF.pc3 file's
MediaBox as 431.8 x 279.4 then 420.158 x 297.039 for A3; the task passes
there with changed.size_mm [[431.8, 279.4], [420.0, 297.0]]. Headless it is
[[432.0, 279.0], [420.0, 297.0]].

tests/test_benchmark_v4.py gains the mutation test the finding describes
(setter stubbed -> both rows must fail; both passed on the old code) and a
run of the gate row itself; the headless task test asserts the B stage.
README rows and the tasks_v4 docstring say what the task now does; the README
collected-tests figure follows the two new tests (3282 -> 3284). The
regenerated A/B report against v1.5.1 is byte-identical (26 -> 32/32).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:27:17 +03:00
Umut CANandClaude Opus 5 c2942d3c94 bench: matrix v4 adds page_setup_truth; correctness suite grows to 32 with three settings checks (A/B vs v1.5.1: 26 -> 32 / 32)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:07:31 +03:00
Umut CANandClaude Opus 5 02dbb133b5 feat(settings): live COM smoke on AutoCAD 2026 builds the five .dwt twins and records the run
scripts/smoke_settings_com.py --build-dwt exited 0 on AutoCAD 2026 (25.1s),
2026-09-22: five templates/*.dwt written through drawing_template_save
(SaveAs code 66, each file opens with AC1032), page setup plotted and the
PDF's own /MediaBox read back as 420.158 x 297.039 mm, iso_a3_mech.dwt
reopened as source "bundled" with ISO-25 current, layer state / named view /
UCS / preferences / SummaryInfo / prompt line all through the COM engine.
The record is in CHANGELOG.md under Unreleased. The smoke keeps a ledger of
the documents it opens and closes them on the way out, so a failed run never
leaves scratch documents on the operator's seat. Two of its expectations
were corrected to the merged tree: mleaderstyle_create is implemented on COM
(spec: no `mleaderstyle` key), and plot_style_list marks catalogue rows with
`installed`. --interactive was not run unattended on the shared seat (an
unanswered GetPoint holds the command line); Task 23 measured the pick live.
*.dwt / *.dwg are binary in .gitattributes; AutoCAD's plot.log is ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:07:31 +03:00
Umut CANandClaude Opus 5 b54271f4ac fix(com): four live defects the settings smoke measured on AutoCAD 2026 — rejected calls after a document switch, typelib spelling of color/Lineweight, narrowed layer-state dictionary, layer 0 Freeze put
Measured 2026-09-22 by scripts/smoke_settings_com.py --build-dwt against the
running AutoCAD 2026 (25.1s), each on a scratch Documents.Add() document:

1. RPC_E_CALL_REJECTED (-2147418111) on Documents.Count straight after
   Documents.Open, after Close(False) inside document_close, and in
   _ensure_document_state: AutoCAD's own message filter refuses incoming
   calls while it is still switching documents. pywin32 exposes no
   CoRegisterMessageFilter, so _wait_out_rejected_call waits the window out
   for read-only callables (0.25 s -> 1 s pauses, 20 s budget, inside
   COM_CALL_TIMEOUT) at those sites and after Documents.Add/Open; any other
   com_error is not retried.
2. layer_list raised "'IAcadLayer' object has no attribute 'Color'. Did you
   mean: 'color'?": acax25enu.tlb spells IAcadEntity/IAcadLayer `color` and
   `Lineweight`. Late-bound dispatch resolved .Color/.LineWeight
   case-insensitively; with a makepy cache present (this machine's gen_py was
   regenerated on 2026-09-17, and every object a method returns is then a
   generated wrapper) the lookup is exact. All 17 sites now use the typelib
   spelling, which works on both wrappers; the fakes model the measured names.
3. layer_state_restore raised "'IAcadObject' object has no attribute
   'Count'": Dictionaries.Item and IAcadDictionary.Item are declared
   IAcadObject* (the narrowing _com_unnarrow already handles for MLEADER
   styles). The save path went through the typed Dictionaries.Add, so only
   the reopen paths failed. Both now un-narrow; the fakes narrow like pywin32.
4. The Freeze put on layer 0 raises 'Invalid layer' whatever the value, even
   False while thawed (color/Linetype/Lineweight/Plottable/LayerOn/Lock/
   ActiveLayer all fine on it), so a state saved with 0 current restored with
   two warnings and 0 unapplied. Freeze is written only when it changes the
   layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:07:31 +03:00
Umut CANandClaude Opus 5 0514f8f15d chore(templates): rebuild the five bundled DXFs on the merged tree (styles via dimstyle_create/textstyle_create, iso_a1_arch annotation scale 1:50)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 12:40:47 +03:00
Umut CANandClaude Opus 5 d072df9fd4 fix(task-24a): apply_standard decides each unit write on a read taken after the dimension style switch
f7528b2 moved the five unit reads of apply_standard ahead of the style
stage so a refused read is a refusal with nothing written -- and then
decided the writes on those reads. Making a dimension style current
restores every DIM* variable from the style (ezdxf: _sync_header_to_dimstyle
on dimstyle_set_current; AutoCAD: ActiveDimStyle), so on a drawing whose
ISO-25/ANSI already carried another scale the gate read (1.0) equalled
the target, the DIMSCALE write was skipped and the drawing ended at the
style's scale while the report said `changed: {}` -- against the spec's
"LTSCALE 1, DIMSCALE 1" and the tool docstring. The pre-f7528b2 loop ran
after the switch and got this right; this is a regression of f7528b2.

The gate read stays (readable before anything is written); each variable
is read again after the style stage and the write is decided on that
value, so `changed` says what the write moved.

Measured on the running AutoCAD 2026 (25.1s, 2026-09-22, scratch
Documents.Add(), Close(False), real ComBackend after connect()):

    ISO-25 modified to DIMSCALE 2.0 (dimstyle_modify -> {'DIMSCALE': [1.0, 2.0]}),
    Annotative current, doc.GetVariable('DIMSCALE') -> 1.0
    before: apply_standard(iso, layers=False)
            -> {'changed': {}, 'applied': True}; ActiveDimStyle ISO-25;
               doc.GetVariable('DIMSCALE') -> 2.0
    after:  -> {'changed': {'DIMSCALE': [2.0, 1.0]}, 'applied': True};
               ActiveDimStyle ISO-25; doc.GetVariable('DIMSCALE') -> 1.0

ezdxf, same setup (dimstyle_create ISO-25 {DIMSCALE: 2.0}, Standard
current): before `{'INSUNITS': [6, 4]}` and DIMSCALE 2.0 afterwards;
after `{'INSUNITS': [6, 4], 'DIMSCALE': [2.0, 1.0]}` and DIMSCALE 1.0.

Tests: one regression per engine (both fail on the f7528b2 apply.py);
test_com_apply_standard_creates_both_styles_and_sets_units now pins the
gate reads first *and* the re-read-then-write sequence after CopyFrom,
instead of enshrining the stale-read order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 12:37:33 +03:00
Umut CANandClaude Opus 5 f7528b2fa5 fix(com): every system variable is read and written on the ActiveX document, not the Application
AcadApplication has no GetVariable/SetVariable members; they live on
AcadDocument. Measured on the running AutoCAD 2026 (25.1s, 2026-09-22,
scratch Documents.Add(), Close(False)):

    hasattr(app, 'GetVariable')  -> False
    hasattr(app, 'SetVariable')  -> False
    app.GetVariable('INSUNITS')  -> AttributeError: ... IAcadApplication
                                    instance ... has no attribute 'GetVariable'
    doc.GetVariable('INSUNITS')  -> 4
    doc.GetVariable('MEASUREMENT') -> 1
    doc.GetVariable('FILEDIA')   -> 1; doc.SetVariable('FILEDIA', 0) -> None,
                                    reads back 0, restored to 1
    doc.GetVariable('AUDITCTL')  -> 0, 'CMDACTIVE' -> 0, 'USERS1' -> ''

Fifteen call sites in backends/com_backend.py still went through the
Application inside `try/except ... log.debug`, so on a live seat they
silently degraded: MEASUREMENT defaulted every seat to metric,
drawing_info reported units from a failed INSUNITS read, drawing_audit
never set AUDITCTL, the CMDACTIVE guards of system_run_command /
system_run_lisp never fired and run_lisp never read USERS1 back. All are
rerouted through `_acad_doc()`; the try/except stays only where an
optional read is defaulted (INSUNITS -> "Unknown", AUDITCTL absent on a
host that lacks it, USERS1 after the expression ran) and the log line
says so.

Linetype loading, measured the same way: `SendCommand("_-LINETYPE _LOAD
CENTER acadiso.lin\n\n")` loads nothing with FILEDIA=0 and leaves the
command at `Enter an option [?/Create/Load/Set]:` (LASTPROMPT), which
makes the whole seat answer RPC_E_CALL_REJECTED to every client until
ESC is pressed; with FILEDIA=1 it opens the modal "Select Linetype File"
dialog. `doc.Linetypes.Load("CENTER", "acadiso.lin")` returns None and
the table grows 3 -> 4; a repeat raises 'Duplicate record name', an
unknown name 'Undefined linetype', a missing file 'File system error'.
`_ensure_linetype_loaded` and `linetype_load` now call `Linetypes.Load`
(MEASUREMENT still read on the document to pick the .lin), touch no
FILEDIA, and report a failure with the English excepinfo description.

`apply_standard` reads every variable it will write before the first
style is created, so a read the engine refuses is a refusal with nothing
written (it used to fail after ISO-25 / ISOCP were created and current).

Fakes: every application fake refuses GetVariable/SetVariable
(tests/test_styles.py's COM fixture carried them on the Application,
enshrining the wrong shape); the members exist on document fakes only.
Regression tests per rerouted site: linetype load via Linetypes.Load with
MEASUREMENT on the document, drawing_info units from the document,
AUDITCTL set/restored on the document, CMDACTIVE/USERS1 on the document,
apply_standard refusing before any write.

Live end-to-end with the real ComBackend on a scratch document:
apply_standard('iso') -> INSUNITS 4, ISO-25 (already in acadiso.dwt,
created: false) and ISOCP (created: true) current, second call created:
false / changed: {}; linetype_load('CENTER') -> Linetypes 3 -> 4, FILEDIA
stays 1, repeat already_loaded, 'NOSUCHLT' refused by name;
_ensure_linetype_loaded('HIDDEN') -> 3 -> 4; drawing_info units 'mm';
drawing_audit AUDITCTL 0 -> 0; system_run_command ok;
system_run_lisp('(+ 40 2)') -> '42'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 12:25:11 +03:00
Umut CANandClaude Opus 5 70afd57831 feat(server): settings pack — 22 environment tools behind TOOL_PACKS, lean grows to 55, three groups, golden set +10
Task 24 (merge sanity) on main after the five track E groups merged.

- TOOL_PACK_NAMES = ("core", "pid", "settings"); PACK_TOOL_NAMES["settings"]
  is exactly the 22 SECTION 20 tools. Styles and page setup stay in core.
- LEAN_TOOL_NAMES += page_setup_apply, batch_plot (53 -> 55).
- _GROUP_TAG_PRIORITY / _GROUP_TAG_LABELS: "style" moved up next to "pid",
  "pagesetup" -> page_setup and "environment" -> environment added; the 28
  SECTION 19/20 decorators gain their section tag, so _tool_groups() reports
  styles 10 / page_setup 6 / environment 22 (23 groups, 204 tools) and
  pack == group == section for the settings pack.
- SECTION headers, backends/base.py composition, contracts/__init__.py
  exports and the layer_states feature entry were already correct after the
  merge (read and confirmed, nothing edited).
- Pack text: config.py, .env.example, README config row; README <sub> line
  204 tools in 23 groups; release snapshot 3259 collected tests (measured).
- docs/tool-inventory.json regenerated; registry snapshot re-derived.
- tests: test_tool_packs.py replaced (11 tests), test_capability_contract.py
  +3 (composition, the agreed capability flags, layer_states honesty),
  golden set +10 tuning cases (81 = 66 tuning + 15 holdout); all ten route
  in the top 3 without alias edits.

Deviation from the plan text: TRACK_E_KEYS carries five keys, not six. The
plan's "mleaderstyle" (ezdxf True / com False) contradicts spec §8.1, which
retracted that key because the ActiveX route exists (§4.2); measured on the
tree: capability_keys() == {boundary_trace, chspace, interactive_prompt,
live_application, preferences, revcloud, wipeout}, and
ComBackend.mleaderstyle_create is a native implementation
(backends/com_backend.py:6752, is_capability_default -> False). The test now
also asserts the key is absent.

No new ActiveX call was written in this task, so no live probe was needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:35:00 +03:00
Umut CANandClaude Opus 5 90c1194f7f merge: track-e group V
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:18:03 +03:00
Umut CANandClaude Opus 5 8e05b5e8eb merge: track-e group P
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:14:02 +03:00
Umut CANandClaude Opus 5 3278586c6e merge: track-e group C
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:12:15 +03:00
Umut CANandClaude Opus 5 0a581a8da3 merge: track-e group S
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:10:22 +03:00
Umut CANandClaude Opus 5 6b3477081c merge: track-e group H
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:10:17 +03:00
Umut CANandClaude Opus 5 d313da783a docs(analysis): Track A review backlog — every minor finding with a disposition
Waves 1-4 of the Track A review produced ~170 minor findings that lived
only in the orchestrator's scratchpad. Filed de-duplicated, one line each:
fixed-in-task-N (this track's hardening tasks), fixed-in-track-A (with the
commit), accepted-limitation (with the reason) or deferred-1.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:08:04 +03:00
Umut CANandClaude Opus 5 efcc8bbba1 fix(task-7): crossings test the exact arc, so a line through a jump's apex counts
flatten_bulges chops a semicircle into an even number of chords (36 for
r=50, 12 for r=5), so a flattened vertex lands exactly on the apex — the
axis of symmetry, the one place a P&ID line most plausibly meets a
crossing jump. segments_cross treats a vertex hit as a junction, so both
neighbouring chords dropped it and the arc contributed 0: the (45,0)->(55,0)
bulge -1 jump reported crossings 0 at x=50 and 1 at x=50.3 or x=49, the
plan's own (0,0)->(100,0) semicircle 0 at x=50, and the mirrored frame
(extrusion -Z) 0 at x=-50.

count_crossings now takes bulges= (per-chain DXF bulge lists, zero-padded)
and tests every bulged edge as its exact arc through the new
segment_arc_crossings — circle/segment intersection restricted to the
sweep — with the same junction rule as the straight case: a hit within
_EPS of the segment's ends, of the arc's own two ends, or a tangent, is
not a crossing. Nothing along the sweep is a vertex any more. The arc
parametrisation is factored into _arc_of_bulge and shared with
flatten_bulges, which keeps its promised signature and its ezdxf pin but
no longer feeds the crossing count. draw_line passes the vertices and
bulges through unflattened.

Pinned in tests: the apex case on the semicircle (x=50, y 20->60), the
r=5 jump at x=50 / 50.3 / 49 on +Z and -Z frames, the junction cases
(T-touch on the arc, through the arc's endpoint, chord side, tangent
along the apex, in-and-out at y=4.9 counts 2), and 4x400 seeded random
segment/arc pairs against ezdxf's ConstructionArc.intersect_line (a
20 000-case run in the worktree found 0 mismatches). The eight new
assertions fail on the flattened rule and pass on the exact one.

Headless finding, headless evidence; no new ActiveX call was written, so
the shared live AutoCAD was not touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:06:04 +03:00
Umut CANandClaude Opus 5 d3ef7b45a2 feat(discovery): style golden queries; dimstyle/textstyle set_current and drawing_apply_standard join the lean profile
Five tuning cases for the styles surface (DDIM, the ISO-25 set-current
paraphrase, the one-call drafting-standard paraphrase, the isocp text
style, and the dimstyle counting question) — measured against the live
catalog every one ranks top-1, so no alias vocabulary changed. The
docstring count moves to seventy-one (56 tuning + 15 holdout); the
holdout set is untouched.

Lean decision (spec §8.2): dimstyle_set_current, textstyle_set_current
and drawing_apply_standard join LEAN_TOOL_NAMES (50 -> 53); the seven
authoring/list tools stay full-only and all ten stay in core (no pack
claims them). Pinned by the new profile test in tests/test_styles.py,
written async like the rest of that module (its module-wide asyncio
mark warns on a sync test).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 10:51:21 +03:00
Umut CANandClaude Opus 5 9cb4bee587 fix(pid): existing line payloads are read tolerantly and crossings follow arcs, not chords
A foreign or hand-edited ACADMCP_PID payload (from: 'garbage', seq: True)
made the next pid_line_draw raise TypeError; payload_port_refs /
payload_seq now accept only well-formed values and a reader result never
raises. An existing polyline with a bulge was tested as its chord, so a
line through the arc reported crossings 0; flatten_bulges (pure, pinned
against ezdxf.math's flattening) feeds count_crossings on both engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 23:05:54 +03:00
Umut CANandClaude Opus 5 a5d166f365 feat(styles): drawing_apply_standard — ISO or ANSI styles, units and layers in one idempotent call
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:49:12 +03:00
Umut CANandClaude Opus 5 b458923e19 feat(templates): drawing_template_save on both engines — dwt_write is a declared capability
Live AutoCAD writes a real .dwt through SaveAs(path, ac2018_Template = 66) and
puts the description into the summary Comments; headlessly a .dxf template is
written through drawing_save_as and a .dwt request is refused with capability
dwt_write, naming the DXF route and the COM route. drawing_save_as's "dwt"
entry was 5 (acR13_dxf): a DWT request wrote an R13 DXF under a .dwt name.

Probed live on AutoCAD 2026 (typelib: ac2018_Template = 66, acR13_dxf = 5,
acTemplateDwg absent; the saved file starts AC1032, the document is rebound,
Comments takes). SECTION 19 is now 6 tools; SAVEAS joins SHARED_ACAD_COMMANDS;
templates/README.md documents the two files per template.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:48:31 +03:00
Umut CANandClaude Opus 5 cfb5d5082c fix(task-6): the reader no longer hands a signal line the neighbouring pipe's label
pid_graph step 5 ran the TEXT label fallback for every edge without a
number. Once signal lines stopped carrying a fabricated number (3c0babf),
every pneumatic/electric run drawn by pid_line_draw entered it, and a
transmitter-to-controller run 12 mm above a labelled header came back as
line_number="100-P-1", number_source="label" — on both engines, with no
confidence field on edges to flag it.

The fallback now skips an edge that carries its own record (an
ACADMCP_PID payload: its `number: null` is the answer) and any
signal-class edge, payload or layer-classified (`signal_unknown`
included): LINE_NUMBER_RE is the pipe grammar, so a match near a signal
line always belongs to a neighbouring pipe. A foreign process line still
takes its label; a verbatim number on a signal line still reads back
from XDATA.

Tests: the finding's layout in test_pid_graph (engine-drawn signal beside
a labelled pipe, verbatim signal number, foreign electric and
signal_unknown lines beside a labelled foreign pipe) and the line_list
row in test_pid_deliverables — three of the four fail on the old reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:44:35 +03:00
Umut CANandClaude Opus 5 021a4eb43d fix(task-11): dimensions_using_style is one filtered selection set, not a per-entity COM walk
_com_dimensions_using walked every entity of every layout and, since the
un-narrowing fix, re-dispatched each one through a dynamic proxy before
reading ObjectName (~8-10 ms per entity), which put dimstyle_modify past
the 60 s COM_CALL_TIMEOUT on a ~6k-entity drawing after its SetVariable +
CopyFrom had already landed. It is now a single
SelectionSet.Select(acSelectionSetAll, (0 . "DIMENSION") (3 . <style>)),
which answers from AutoCAD's own index whatever the drawing size.

Measured live (AutoCAD 2026, scratch doc, 1000 LINEs + 14 dimensions across
model and paper space): walk 12.7-15.8 s, selection 0.02-0.09 s, identical
handle sets; the whole dimstyle_modify tool now returns in 0.07 s.

The group-3 filter value is a wcmatch pattern, so the style name is
backtick-escaped (_wcmatch_literal): unescaped, "A.B" also selected the
dimensions of "A_B", "N#1" those of "N21", "P[1]" those of "P1", "S@X"
those of "SAX" (measured). Handles are returned in ascending handle order,
Select failures propagate, and the selection set is deleted either way.

tests/test_styles.py: the fake document carries a SelectionSets that
emulates ssget "X" over every layout with wcmatch-literal semantics and
narrowed IAcadEntity items, refusing an unescaped wildcard; three tests
cover no-per-entity-read, escaping, and cleanup-on-failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:39:08 +03:00
Umut CANandClaude Opus 5 17f7228fd6 fix(task-19): a .dxf template really reaches the live seat; the fallback dimvars land on Standard
Documents.Add(<file>) accepts only a genuine DWT. Measured on AutoCAD 2026:
Add(templates/iso_a3_pid.dxf) returned the default acadiso drawing (layers
['0'], Layout1/Layout2) exactly as Add() and Add(<missing.dwt>) did, so
drawing_new(template=<bundled name>) reported ok:true with source
'bundled_dxf' over a blank document, and the explicit .dxf path route was
just as blank. ComBackend.drawing_new now converts any non-.dwt template
through AutoCAD itself (Documents.Open read-only -> SaveAs ac2018_Template,
older constants tried next -> Close(False)), caches the .dwt in the temp
folder keyed on path/size/mtime, hands Add the .dwt, and refuses a missing
template file instead of letting Add default silently. The tool result
carries template.dwt / template.dwt_cached. Re-measured end to end through
server.drawing_new on the real ComBackend: ansi_b_mech -> 11 layers, tab B,
DIMTAD 0 / DIMTIH 1 / DIMEXO 1.5; iso_a3_pid -> 17 layers, tab A3, second
call reuses the cached .dwt (0.8 s conversion once). Fake-ActiveX tests pin
the call shape and that the conversion document never stays open.

Headlessly the build's fallback wrote spec 4.1 only as header DIM variables,
and the renderer folds six of them, so DIMTAD/TIH/TOH/EXE/EXO/GAP/LUNIT/LWD
were a no-op: ansi_b_mech dimensioned with ISO text placement while
system_get_variable and AutoCAD said ANSI. The fallback now also stores each
value on the Standard dimstyle; the five DXFs are rebuilt (--check passes)
and a test reads the effective values off a DIMENSION drawn from each
template and asserts header and style agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:31:44 +03:00
Umut CANandClaude Opus 5 3c0babfef1 fix(pid): signal lines carry no fabricated number; a bubble exits towards the first waypoint
An ISA-5.1 signal line has no pipe line number, yet every pneumatic /
electric line got the bare sequence as its number and a label, and ate a
process sequence number. Signal classes now leave line_number None (a
verbatim one is still honoured), seq None, no label. A radial port aimed
at the far end even with waypoints, so the first segment cut through the
bubble; aim_points (lines.py) aims at the first/last waypoint for both
draw_line and the dry run.

Merge note: benchmarks/adapters/autocad_mcp_pro.py (final group's file)
had its two _task_pid_roundtrip assertion lines moved to the new truth —
the signal row is unnumbered, the connector row is 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:24:03 +03:00
Umut CANandClaude Opus 5 03a5067d83 fix(task-11): live text styles write TrueType by typeface, refuse missing fonts and arrowhead blocks before any write, un-narrow the dimension walker
Four review findings against the COM styles block, all measured on AutoCAD 2026
(scratch documents from Documents.Add, closed without saving):

1. textstyle_create: `TextStyle.fontFile = "arial.ttf"` raises 'Filer error' for
   every bare TrueType name (bundled presets included) after TextStyles.Add has
   run, leaving a stub with an empty font that a retry meets as "already exists";
   a full path is accepted but the STYLE record then carries the machine path.
   `SetFont("Arial", bold, italic, 0, 0)` — the STYLE dialog's own route — stores
   the clean `arial.ttf`. The font is now located before Add (`_com_locate_font`:
   support path, then the Windows font folders), a TrueType is written by the
   family/subfamily read from its `name` table (`_truetype_face`), an SHX by the
   name given, an unlocatable file is refused with nothing written, and a write
   ActiveX still refuses after Add deletes the entry it added.
2. _ensure_com_textstyle goes through the same helper, so DIMTXSTY / text_style
   ARIAL and ISOCPEUR are created on the first call with a real font instead of
   failing once and then silently binding a fontless style.
3. dimstyle_create / dimstyle_modify check a user DIMBLK/DIMBLK1/DIMBLK2 against
   doc.Blocks before any write (`_com_require_arrowhead_blocks`, the headless
   `_require_arrowhead_blocks` twin, same message and table-spelling rewrite);
   a write that fails after DimStyles.Add restores the previous ActiveDimStyle
   (discarding the unsaved overrides) and deletes the half-made style and any
   preset text style the call created.
4. _com_dimensions_using un-narrows each `Layouts.Item(i).Block.Item(p)` —
   declared IAcadEntity*, so `.StyleName` raised AttributeError under a makepy
   cache and the blanket except reported `[]` — and swallows nothing.

The fake in tests/test_styles.py now models the measured ActiveX rules (bare
TrueType fontFile refused, SetFont by installed typeface, SetVariable refusing an
unknown arrowhead, IAcadEntity narrowing on block items, Delete) so the old code
cannot pass; nine tests cover the four findings. SECTION 18 docstrings state the
live engine's refusal rule for fonts and the pre-write arrowhead check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:17:30 +03:00
Umut CANandClaude Opus 5 dda11755cf fix(task-16): custom keys follow AutoCAD's simple case compare, and on COM AutoCAD routes the write
4fe2d96 matched custom keys with str.casefold(), but AutoCAD's key compare
is a simple one-to-one case compare, not Unicode full case folding.
Measured on AutoCAD 2026 (AddCustomInfo of the second spelling over the
first on a scratch document, Close(False)): Project/PROJECT, Grün/GRÜN,
é/É, ÿ/Ÿ, ł/Ł, σ/Σ, я/Я and dž/DŽ are one key ('Duplicate key'), while
Straße/STRASSE, ẞ/ß, kelvin/Kelvin-sign, µ/Μ, σς/ΣΣ and Dž/DŽ are two keys
(both stored) -- every one of those six is casefold()-equal. So with
'Straße' present, `custom={'BEFORE': 'b', 'STRASSE': 's2', 'AFTER': ...}`
was routed to SetCustomByKey, AutoCAD answered 'Key not found', and the
request raised with subject and BEFORE applied and AFTER never written --
the half-write class the fix claimed to close; a delete of 'STRASSE' over
'Straße' raised the same way; headlessly 'STRASSE' overwrote the 'Straße'
value instead of becoming a second tag; and the validator refused
{'Straße', 'STRASSE'} with a reason AutoCAD contradicts.

COM no longer routes from a Python approximation. An exact spelling goes
to SetCustomByKey; anything else is AddCustomInfo, and its 'Duplicate key'
(excepinfo scode -2145386475, measured) falls back to SetCustomByKey. A
delete is RemoveCustomByKey, and its 'Key not found' (scode -2145386476)
is reported as not deleted. Any other com_error keeps propagating. The
scode is matched before the English description because the HRESULT text
is localised.

The headless engine and the validator match on `custom_key_fold`: a
per-character fold to lowercase only when lower()/upper() are single
characters that round-trip and the character is one of the two; it never
changes string length and reproduces every measured pair (ß, ς, µ, the
Kelvin sign, Dž, ı and İ stay as they are). Docstrings in settings.py and
server.py now state that rule instead of 'case-insensitive'.

Tests: parametrized fold over the measured SAME / DISTINCT table (plus a
pin that casefold() merges the six DISTINCT pairs, which is why 4fe2d96
regressed), validator acceptance of each DISTINCT pair, headless
side-by-side storage and deletion of each pair, the fake SummaryInfo now
matches by the same fold and raises pywintypes.com_error with the
measured excepinfo tuples, the COM half-write scenario for each pair
(no SetCustomByKey guess, 'Key not found' delete tolerated, rest of the
request lands), the 'Duplicate key' fallback, and an unrelated
SummaryInfo failure still raising. The scodes were measured live before
the change; AutoCAD stopped answering COM (blocked in another agent's
command or a modal dialog) before the post-change re-run, so that run
is recorded as not done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:04:22 +03:00
Umut CANandClaude Opus 5 a76d9e08a3 feat(templates): five bundled templates built by the server's own tools; drawing_new(template=name)
SECTION 19 grows to five tools with drawing_template_list; drawing_new takes a
bundled name (iso_a3_mech, iso_a1_arch, iso_a3_pid, ansi_b_mech, ansi_d_arch)
or a .dwt/.dxf path and reports template: {name|path, path, source}.

scripts/build_templates.py builds templates/*.dxf through EzdxfBackend
(layer set, header DIM variables until group S's styles land, drawing_settings,
sheet tab, ISO 5457 frame on A3, page_setup_apply) and --check pins them
reproducible. One drift the plan's same-process measurement could not see is
fixed at the source rather than masked: ezdxf orders CLASSES by set iteration,
so two processes disagreed on LAYOUT / ACDBPLACEHOLDER; the section is re-keyed
(required list first, then by name) before the save.

The root .gitignore ignores *.dxf as generated output; templates/*.dxf is
un-ignored there, since the bundled set is the one DXF that is committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:03:41 +03:00
Umut CANandClaude Opus 5 86c5d6ad65 fix(task-18): the headless Model plot is doc.modelspace(), not the current tab
EzdxfBackend.drawing_export_pdf routed layout=None / layout="Model" through
_msp(), which follows the current tab, so with a sheet current the "Model"
PDF was that sheet's paper-space content with no refusal — the same defect
finding 3 named on COM, still live on the headless engine. Measured: circle
in model space, 390x270 rectangle on an A3 sheet, sheet current — the Model
row's /MediaBox was 176.1x121.9 mm (the rectangle) instead of 121.9x121.9
(the circle). The model target is now doc.modelspace() whichever tab is
current, on both entry points.

The test that vouched for this only asserted the row's shape (paper is None,
true by construction). It is replaced by two tests that prove content: the
Model row's mediabox with a sheet current equals the one with Model current
and carries the circle's square aspect, and drawing_export_pdf(path) with no
layout does the same. Both fail without the backend change. plot.py's
docstring now says which engine does what.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:47:05 +03:00
Umut CANandClaude Opus 5 2e101b95e2 fix(task-5): a rollback that fails after a cancellation no longer kills the session, and the COM rollback goes back to an UNDO Mark instead of prompting
Two review findings on 32821c6.

1. Once the shielded rollback body runs after a real client cancellation it
   can fail (the COM hard timeout, _safe_send_command's own deadline, an
   ezdxf quarantine refusal). That RuntimeError escaped run_spec as an
   ordinary error and the mcp lowlevel server, which had already answered
   the cancelled request, tried to respond a second time: `assert not
   self._completed` in mcp/shared/session.py escaped the server task group
   and the whole session died (measured through the real transport: the next
   system_status was never answered and Client.__aexit__ re-raised the
   AssertionError). run_spec now decides by the exception it is handling:
   after a cancellation the rollback error is logged and the cancellation is
   what propagates, so the SDK's cancellation suppression applies; after any
   other error the rollback error propagates as before, with the original
   chained as its context.

2. The docstring promised the rollback on both engines, but on the live
   AutoCAD 2026 ComBackend.transaction_rollback sent `_UNDO B` with no UNDO
   Mark in the stack (StartUndoMark/EndUndoMark form an undo *group*, not a
   Mark), AutoCAD answered "This will undo everything. OK? <Y>", SendCommand
   blocked at that prompt, _run gave up after 60 s, the entities stayed and
   every COM client was rejected until a human pressed ESC. transaction_begin
   now types `_.UNDO _M` before StartUndoMark (refused with ok:false while
   CMDACTIVE, where the Mark would be swallowed as input), and
   transaction_rollback ends the group and sends `_.UNDO _B`, which returns
   exactly to that Mark - also on an empty transaction, where `_UNDO 1` would
   be one step too many. A rollback with no transaction is refused the way the
   headless engine refuses it, because an unmarked Back is that prompt.
   Measured with the real ComBackend in a scratch Documents.Add() document on
   the running AutoCAD 2026 (closed with Close(False)): begin 31 ms ->
   entity_create_line + circle -> rollback 63 ms -> 3 entities back to 1,
   CMDACTIVE 0, no prompt; an empty begin/rollback leaves the older line;
   begin/commit unchanged.

Tests: the two failure modes on run_spec (anyio scope cancellation with a
raising rollback completes the task group and logs; a ValueError with a
raising rollback still raises the rollback error with the ValueError as
context), the real-transport case (client.cancel mid-run, rollback raises,
the next request is answered, cleanup through the real rollback), the COM
shape now pins `_.UNDO _M, StartUndoMark, EndUndoMark, _.UNDO _B`, and
test_com_sprint3 grows a Mark-keeping fake document whose unmarked Back
fails the way the prompt does: begin before the group, rollback consumes the
Mark, rollback without a transaction sends nothing, begin refuses on
CMDACTIVE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:46:08 +03:00
Umut CANandClaude Opus 5 a60f801205 feat(server): SECTION 18 styles — dimstyle/textstyle/mleaderstyle tools with DIMSTYLE/STYLE/MLEADERSTYLE aliases
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:44:02 +03:00
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 f6951e2597 fix(task-18): the COM plot is foreground, by layout name, and confirmed on disk; plot_style_list reads PrinterStyleSheetPath
Three review findings against SECTION 19 on the live engine, each measured
on AutoCAD 2026 in a scratch document with the operator's BACKGROUNDPLOT=2:

- plot_style_list read Preferences.Files.PrintStyleSheetPath; the ActiveX
  member is PrinterStyleSheetPath, so the AttributeError was swallowed and
  every row came back installed=None. Renamed in the backend, the server
  docstring and the test fake (which faked the same wrong name). Live: 13
  rows, all 9 catalogue ctb files installed, four .stb files as installed.
- ComBackend.drawing_export_pdf returned ok before any file existed under
  background plotting, so batch_plot died on path.stat() with an unhandled
  FileNotFoundError and a later foreground plot raised E_FAIL. The export
  now sets BACKGROUNDPLOT=0 around PlotToFile (restored after), honours the
  method's bool and reports ok only with the file on disk (bytes included).
  batch_plot turns a backend RuntimeError or a phantom ok into a per-row
  ok:false, keeping the rows already plotted.
- With a sheet current, the Model row was that sheet plotted again: the COM
  export switched tabs only when a layout was named. layout=None now makes
  Model current (restoring the tab), and batch_plot names every layout,
  Model included, so both engines plot the tab the row is labelled with.
  Live: Model row /Title (Model) at 210x297 beside the A3 sheet, the active
  tab and BACKGROUNDPLOT restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:36:07 +03:00
Umut CANandClaude Opus 5 2052915f34 fix(task-23): user_select reports the picked point in WCS — GetEntity answers in the current UCS, so translate acUCS→acWorld before returning
Measured on AutoCAD 2026: under a UCS at (100,50) rotated 90 deg, a pick on a
circle at WCS (105,70) came back from IAcadUtility.GetEntity as (20,-5,0),
while Utility.GetPoint already answered in WCS. A model doing ucs_set ->
user_select -> entity_create_* at `picked` would place geometry at a silently
wrong position. The point goes through Utility.TranslateCoordinates(acUCS,
acWorld) packed as a VT_ARRAY|VT_R8 VARIANT (the plain tuple GetEntity hands
back is refused as "Invalid argument Point"). The fake Utility now models the
UCS frame and the VARIANT requirement; a new test pins the measured figures
and checks GetPoint stays untranslated. Docstrings state WCS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:34:26 +03:00
Umut CANandClaude Opus 5 266b6fb838 fix(task-10): un-narrow the ACAD_MLEADERSTYLE dictionary route so mleaderstyle_list/create work on the live engine
acax25enu.tlb declares IAcadDictionaries.Item, IAcadDictionary.Item and
IAcadDictionary.AddObject as returning IAcadObject*. With a makepy cache for
the AutoCAD type library present (it is, on any machine where gencache ever
ran against AutoCAD), pywin32 wraps each result in the gen_py class of the
declared type, which exposes only IAcadObject members: .Count/.Item/.GetName
on the dictionary and .ArrowSize/.LandingGap/.TextHeight/.TextStyle on a
style all raised AttributeError, so the previous round's removal of the
@capability refusal turned a typed UnsupportedCapabilityError into a raw
AttributeError on AutoCAD 2026.

`_com_unnarrow` re-dispatches an object on its raw IDispatch through
win32com.client.dynamic.Dispatch (no gencache side effect, unlike CastTo),
and the dictionary route applies it to the dictionary, to every Item(i) and
to the AddObject result. Verified live on AutoCAD 2026 (scratch document,
closed afterwards): list reads Standard = 0.18/0.09/0.18/Standard, create
writes ISO-LDR = 2.5/1.0/2.5/ISOCP, the duplicate is refused, and an
independent late-bound read of the dictionary agrees.

The fake in tests/test_styles.py now models the narrowing: Dictionaries.Item,
Dictionary.Item and Dictionary.AddObject hand back a wrapper that answers only
IAcadObject members and raises on everything else (reads and writes alike)
until unwrapped through _oleobj_. Five COM tests fail against the previous
backend and pass now; a new test pins the missing-dictionary path
(Dictionaries.Add before AddObject).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 18:54:43 +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 f25421709f feat(pagesetup): SECTION 19 — page_setup_list/apply, plot_style_list, batch_plot with /MediaBox read-back
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 18:39:34 +03:00