feat(refs): xrefs, raster underlays, data extract and real DWG output
SECTION 24 completes at eleven tools with xref_attach, xref_manage,
image_attach, data_extract and drawing_export_dwg, on the four contract members
that genuinely differ per engine -- backends/contracts/refs.py. Everything else
in the sheet track composes the generic members and is written once.
The ActiveX members were read off the AutoCAD 2026 seat's own type library
(C:\Program Files\Common Files\Autodesk Shared\acax25enu.tlb, "AutoCAD 2025
Type Library" 1.0, the library the running 25.x seat registers) before a line of
the COM path was written, and the measurement is in the code beside the calls:
IAcadModelSpace.AttachExternalReference(PathName, Name, InsertionPoint,
Xscale, Yscale, Zscale, Rotation, bOverlay, Password)
IAcadModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor,
RotationAngle)
IAcadBlock: IsXRef (get), Path (get AND put -- so the repath is an assignment,
not a method), Reload(), Unload(), Bind(bPrefixName), Detach(),
XRefDatabase (get)
IAcadDocument.SaveAs(FullFileName, SaveAsType, vSecurityParams)
AcSaveAsType: ac2000_dwg=12 ac2004_dwg=24 ac2007_dwg=36 ac2010_dwg=48
ac2013_dwg=60 ac2018_dwg=64 (=acNative)
The running seat itself was not answering COM while this landed (its window was
Not Responding and GetActiveObject never returned), so the type library -- which
is the same seat's declared surface and reads without a call into the process --
is what the members are measured from; no document of the operator's was opened
or touched. This repository has been bitten four times by a fake that invented a
member, so the COM fakes in the tests model those names and nothing else.
ActiveX angles are radians, so the degree-to-radian conversion happens at that
boundary and nowhere else.
Headlessly, ezdxf.xref.attach carries the attachment (measured on ezdxf 1.4.4:
block flags 20 for an attachment and 24 for an overlay, so "is this an xref" is
flags & (BLK_XREF | BLK_XREF_OVERLAY) -- masking BLK_XREF alone misses every
overlay), add_image_def + add_image the raster, with Pillow reading the pixel
size so the placed image keeps the file's aspect.
Three capability keys, declared in both maps:
dwg_write -- measured absent here (odafc.is_installed() is False), so the
ezdxf entry is a runtime re-evaluation like viewport_render's
matplotlib check rather than a hardcoded False: installing the
ODA File Converter starts the call working with no edit to the
map. The refusal names the install route and drawing_export_dxf.
xref_live -- reload and bind need a live seat's xref manager and are refused
headlessly; list, detach and path are headless and are not
behind the key.
xlsx_write -- openpyxl is optional and absent here; CSV is always available,
so the refusal never blocks the deliverable and says so.
Two numbers the plan predicted wrongly are corrected to what the registry
actually does. `block` outranks `drawing` in _GROUP_TAG_PRIORITY, so the two
xref tools file under `blocks` (9 -> 11) beside block_insert -- which is where
an xref belongs, being a block definition that lives in another file -- and
`drawing` moves 11 -> 12, not 11 -> 14. And `data_extract` must not say "parts
list" in its own text: that is the golden discovery query for
entity_create_table, and a file writer outranking the tool that draws the thing
is a regression the tuning bar catches. It is described by its destination --
a file -- instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
de8dc8f813
commit
3ef316d520
@@ -8124,7 +8124,7 @@ async def drawing_properties_set(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── SECTION 24: Sheet & Delivery (6 tools) ──────────────────────────────────
|
||||
# ── SECTION 24: Sheet & Delivery (11 tools) ─────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -8574,6 +8574,181 @@ async def balloon_add(
|
||||
)
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Attach a DWG/DXF as an external reference (attachment or overlay).", cost="mutate"
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Sheet: Attach Xref", "destructiveHint": False},
|
||||
tags={"drawing", "block"},
|
||||
)
|
||||
async def xref_attach(
|
||||
path: Annotated[str, Field(description="Full path of the drawing to reference.")],
|
||||
x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0,
|
||||
y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0,
|
||||
scale: Annotated[float, Field(default=1.0, gt=0, description="Uniform scale.")] = 1.0,
|
||||
rotation: Annotated[
|
||||
float, Field(default=0.0, description="Rotation in degrees, CCW from +X.")
|
||||
] = 0.0,
|
||||
kind: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="attach",
|
||||
description="'attach' (the reference's own xrefs come with it) or 'overlay' "
|
||||
"(they do not, so a circular reference cannot form).",
|
||||
),
|
||||
] = "attach",
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Attach an external reference and insert it once. Both engines.
|
||||
|
||||
The block takes the referenced file's stem as its name. Refusals, all
|
||||
before anything is written: a `kind` that is not 'attach' or 'overlay', a
|
||||
file that does not exist, a non-positive or non-finite scale, and a block
|
||||
name the drawing already holds — detach it first with
|
||||
`xref_manage(action='detach')`.
|
||||
"""
|
||||
validated = validate_path(path)
|
||||
await ctx.info(f"Xref {kind}: {validated}")
|
||||
return await _backend(ctx).xref_attach(str(validated), (x, y), scale, rotation, kind)
|
||||
|
||||
|
||||
@cad_tool(summary="List, reload, bind, detach or repath an external reference.", cost="mutate")
|
||||
@mcp.tool(
|
||||
annotations={"title": "Sheet: Manage Xrefs", "destructiveHint": True},
|
||||
tags={"drawing", "block"},
|
||||
)
|
||||
async def xref_manage(
|
||||
action: Annotated[
|
||||
str,
|
||||
Field(description="list | reload | bind | detach | path."),
|
||||
],
|
||||
name: Annotated[str, Field(default="", description="Xref name; ignored by 'list'.")] = "",
|
||||
new_path: Annotated[
|
||||
str, Field(default="", description="Required by 'path': the reference's new location.")
|
||||
] = "",
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""One operation on one external reference.
|
||||
|
||||
`list`, `detach` and `path` work on both engines. `reload` and `bind` need
|
||||
a live AutoCAD seat's xref manager: headlessly they are refused with
|
||||
`capability: "xref_live"` rather than pretended, and the refusal says which
|
||||
engine does them. Other refusals: an action outside the five, an unknown
|
||||
xref name, a 'path' action with no `new_path`. On the live engine a listed
|
||||
row's `inserts` is null — ActiveX reports no per-xref insert count and this
|
||||
server does not invent one.
|
||||
"""
|
||||
await ctx.info(f"Xref {action} {name or '(all)'}")
|
||||
validated = str(validate_path(new_path)) if new_path else None
|
||||
return await _backend(ctx).xref_manage(name, action, validated)
|
||||
|
||||
|
||||
@cad_tool(summary="Attach a raster image (PNG/JPG) as an underlay.", cost="mutate")
|
||||
@mcp.tool(
|
||||
annotations={"title": "Sheet: Attach Image", "destructiveHint": False},
|
||||
tags={"drawing", "create"},
|
||||
)
|
||||
async def image_attach(
|
||||
path: Annotated[str, Field(description="Full path of the raster image.")],
|
||||
x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0,
|
||||
y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0,
|
||||
scale: Annotated[
|
||||
float,
|
||||
Field(
|
||||
default=1.0,
|
||||
gt=0,
|
||||
description="Drawing units per pixel: an 800x400 image at 0.1 is 80x40 mm.",
|
||||
),
|
||||
] = 1.0,
|
||||
rotation: Annotated[float, Field(default=0.0, description="Rotation in degrees.")] = 0.0,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Attach a raster underlay at the file's own aspect ratio. Both engines.
|
||||
|
||||
`scale` is drawing units per pixel, so the placed size is the image's pixel
|
||||
size times `scale` and the payload reports both. Refusals, all before
|
||||
anything is written: a file that does not exist, a file no image reader can
|
||||
open (named), a non-positive or non-finite scale.
|
||||
"""
|
||||
validated = validate_path(path)
|
||||
await ctx.info(f"Image underlay: {validated}")
|
||||
return await _backend(ctx).image_attach(str(validated), (x, y), scale, rotation)
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.",
|
||||
cost="safe",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Sheet: Extract Data", "destructiveHint": False},
|
||||
tags={"engineering", "sheet", "query"},
|
||||
)
|
||||
async def data_extract(
|
||||
path: Annotated[str, Field(description="File to write, ending .csv or .xlsx.")],
|
||||
fmt: Annotated[
|
||||
str,
|
||||
Field(default="csv", description="'csv' (always available) or 'xlsx' (needs openpyxl)."),
|
||||
] = "csv",
|
||||
layer: Annotated[
|
||||
str, Field(default="", description="Only read INSERTs on this layer; empty reads all.")
|
||||
] = "",
|
||||
group_by: Annotated[
|
||||
str,
|
||||
Field(default="designation", description="Group identical parts by this field, or ''."),
|
||||
] = "designation",
|
||||
columns: Annotated[list[str] | None, Field(default=None)] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""The same rows `bom_extract` returns, written to a file.
|
||||
|
||||
Refusals: a format that is not 'csv' or 'xlsx'; a drawing with nothing to
|
||||
itemise (nothing is written); and `fmt='xlsx'` without openpyxl, which
|
||||
carries `capability: "xlsx_write"` and names both the package to install
|
||||
and the CSV route that always works.
|
||||
"""
|
||||
from engineering.sheet.bom import extract_records, rows_from_records
|
||||
from engineering.sheet.extract import write_rows
|
||||
|
||||
backend = _backend(ctx)
|
||||
validated = validate_path(path, allow_write=True)
|
||||
records = await extract_records(backend, layer=layer or None)
|
||||
rows = rows_from_records(records, columns=columns, group_by=group_by or None)
|
||||
await ctx.info(f"Data extract: {len(rows)} rows -> {validated}")
|
||||
return write_rows(rows, str(validated), columns=columns, fmt=fmt)
|
||||
|
||||
|
||||
@cad_tool(summary="Write the drawing as a real DWG at a chosen AutoCAD version.", cost="safe")
|
||||
@mcp.tool(
|
||||
annotations={"title": "Drawing: Export DWG", "destructiveHint": False},
|
||||
tags={"drawing"},
|
||||
)
|
||||
async def drawing_export_dwg(
|
||||
path: Annotated[str, Field(description="Full path of the .dwg to write.")],
|
||||
version: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="R2018",
|
||||
description="R2000 | R2004 | R2007 | R2010 | R2013 | R2018.",
|
||||
),
|
||||
] = "R2018",
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Write a real DWG — not a DXF under a .dwg name.
|
||||
|
||||
On a live AutoCAD seat this is `Document.SaveAs` with the matching
|
||||
AcSaveAsType, which (as AutoCAD's own SaveAs does) leaves the session bound
|
||||
to the file it just wrote. Headlessly it needs the ODA File Converter; when
|
||||
that is absent the call is refused with `capability: "dwg_write"` and the
|
||||
refusal names the install route and `drawing_export_dxf`, which writes a
|
||||
DXF AutoCAD opens unchanged. Check `system_capabilities` first: the ezdxf
|
||||
flag is re-evaluated at call time, so installing the converter changes it
|
||||
without a restart. An unknown version is refused by name with the six.
|
||||
"""
|
||||
validated = validate_path(path, allow_write=True)
|
||||
await ctx.info(f"DWG export {version}: {validated}")
|
||||
return await _backend(ctx).drawing_export_dwg(str(validated), version)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user