merge: tracks B+G group G

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 12:25:45 +03:00
co-authored by Claude Opus 5.5
26 changed files with 5451 additions and 342 deletions
+629
View File
@@ -9120,6 +9120,635 @@ async def hatch_material(
)
# ---------------------------------------------------------------------------
# ── SECTION 24: Sheet & Delivery (11 tools) ─────────────────────────────────
# ---------------------------------------------------------------------------
@cad_tool(
summary="Draw an ISO 5457 sheet frame with the zone grid and centring marks.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Sheet: ISO 5457 Frame", "destructiveHint": False},
tags={"engineering", "sheet"},
)
async def sheet_frame(
size: Annotated[
str, Field(default="A3", description="ISO 216 sheet: A4, A3, A2, A1 or A0.")
] = "A3",
orientation: Annotated[
str, Field(default="landscape", description="'landscape' or 'portrait'.")
] = "landscape",
zones: Annotated[
bool,
Field(
default=True,
description="Draw the ISO 5457 grid reference system: letters down the vertical "
"edges from the top, numbers along the horizontal edges from the left.",
),
] = True,
marks: Annotated[
bool,
Field(
default=True,
description="Draw the four centring marks and the eight corner trimming rectangles.",
),
] = True,
origin_x: Annotated[
float, Field(default=0.0, description="X of the sheet's lower-left corner (WCS).")
] = 0.0,
origin_y: Annotated[
float, Field(default=0.0, description="Y of the sheet's lower-left corner (WCS).")
] = 0.0,
layout: Annotated[
str,
Field(
default="",
description="Paper-space layout to draw the sheet on (create it with "
"layout_create). Empty draws in the current space.",
),
] = "",
ctx: Context = None,
) -> dict:
"""ISO 5457 drawing frame for A4-A0: a 20 mm filing margin, 10 mm on the
other three edges, the grid reference system and the centring/trimming marks.
Borders, rules and marks land on TITLEBLOCK; the zone lettering on TEXT.
Refusals, all before the first entity is drawn: a size outside A4-A0 is
refused by name and the five covered sizes are listed; an orientation that
is neither 'landscape' nor 'portrait'; a `layout` that does not exist. The
zone divisions drawn are equal -- ISO 5457's shorter corner fields are not
implemented -- while the division counts are the standard's.
"""
from engineering.sheet.frames import draw_sheet_frame
await ctx.info(f"Sheet frame {size} {orientation}")
return await draw_sheet_frame(
_backend(ctx),
size,
orientation=orientation,
zones=zones,
marks=marks,
origin=(origin_x, origin_y),
layout=layout or None,
)
@cad_tool(
summary="Stamp an ISO 7200 title block (and frame) on any sheet from A4 to A0.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Sheet: ISO 7200 Title Block", "destructiveHint": False},
tags={"engineering", "sheet"},
)
async def titleblock_apply(
title: Annotated[str, Field(description="Drawing title (verbatim, no transformation).")],
drawing_no: Annotated[str, Field(description="Identification number, e.g. 'AM-2026-001'.")],
size: Annotated[
str, Field(default="A3", description="ISO 216 sheet: A4, A3, A2, A1 or A0.")
] = "A3",
orientation: Annotated[
str, Field(default="landscape", description="'landscape' or 'portrait'.")
] = "landscape",
projection: Annotated[
str,
Field(
default="first",
description="Projection-angle symbol: 'first' (ISO/European, the default), "
"'third' (ANSI), or '' for no symbol.",
),
] = "first",
part_no: Annotated[str, Field(default="")] = "",
material: Annotated[str, Field(default="")] = "",
scale: Annotated[str, Field(default="1:1")] = "1:1",
units: Annotated[str, Field(default="mm")] = "mm",
drawn_by: Annotated[str, Field(default="")] = "",
checked_by: Annotated[str, Field(default="")] = "",
date: Annotated[str, Field(default="")] = "",
sheet: Annotated[str, Field(default="1/1")] = "1/1",
revision: Annotated[str, Field(default="A")] = "A",
company: Annotated[str, Field(default="Anka-Makine")] = "Anka-Makine",
frame: Annotated[
bool, Field(default=True, description="Also draw the ISO 5457 frame around the block.")
] = True,
zones: Annotated[
bool, Field(default=False, description="Add the zone grid to that frame.")
] = False,
marks: Annotated[
bool, Field(default=False, description="Add the centring and trimming marks.")
] = False,
origin_x: Annotated[float, Field(default=0.0)] = 0.0,
origin_y: Annotated[float, Field(default=0.0)] = 0.0,
layout: Annotated[
str,
Field(
default="",
description="Paper-space layout to draw the sheet on; empty draws in the "
"current space.",
),
] = "",
ctx: Context = None,
) -> dict:
"""ISO 7200 title block, 180 mm wide, in the lower-right corner of any
A4-A0 sheet. The title text is used verbatim.
Carries the ISO 128-30 projection-angle symbol: 'first' draws the cone on
the left and the concentric circles on the right, 'third' is its mirror.
Refusals, all before the first entity: an unknown size (the five covered
sizes are listed), an unknown orientation, a projection that is not
'first'/'third'/'', a `layout` that does not exist. `titleblock_apply_iso_a3`
is this tool with size='A3' and no symbol, kept so no caller breaks.
"""
from engineering.sheet.titleblock import TitleBlockMetadata, apply_titleblock
await ctx.info(f"Title block {size} {orientation}: {title}")
metadata = TitleBlockMetadata(
title=title,
drawing_no=drawing_no,
part_no=part_no,
material=material,
scale=scale,
units=units,
drawn_by=drawn_by,
checked_by=checked_by,
date=date,
sheet=sheet,
revision=revision,
company=company,
)
return await apply_titleblock(
_backend(ctx),
size=size,
metadata=metadata,
origin=(origin_x, origin_y),
orientation=orientation,
projection=projection or None,
frame=frame,
zones=zones,
marks=marks,
layout=layout or None,
)
@cad_tool(
summary="Append a revision row, with optional revision clouds and triangular tags.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Sheet: Add Revision", "destructiveHint": False},
tags={"engineering", "sheet"},
)
async def revision_add(
rev: Annotated[
str, Field(description="Revision code: one to three capitals or digits (A, B, 01).")
],
description: Annotated[str, Field(description="What changed, verbatim.")],
date: Annotated[str, Field(default="", description="Issue date, verbatim.")] = "",
by: Annotated[str, Field(default="", description="Who issued it.")] = "",
clouds: Annotated[
list[list[float]] | None,
Field(
default=None,
description="Regions to cloud, each [x0, y0, x1, y1] in WCS. Refused before "
"anything is written on an engine without the revcloud capability.",
),
] = None,
tags: Annotated[
list[list[float]] | None,
Field(default=None, description="Points [x, y] to put a triangular revision tag at."),
] = None,
size: Annotated[
str, Field(default="A3", description="Sheet the block is placed on: A4-A0.")
] = "A3",
orientation: Annotated[str, Field(default="landscape")] = "landscape",
origin_x: Annotated[float, Field(default=0.0)] = 0.0,
origin_y: Annotated[float, Field(default=0.0)] = 0.0,
cloud_segment: Annotated[
float, Field(default=8.0, gt=0, description="Revision-cloud arc segment length (mm).")
] = 8.0,
layout: Annotated[str, Field(default="")] = "",
ctx: Context = None,
) -> dict:
"""Append one row to the revision block, creating the block on first use.
Idempotent per revision code: a code the drawing already carries returns
`created=false` and draws nothing, so re-running a revision pass cannot
stack duplicates. The block defaults to the upper-right corner of the
drawing frame, growing downwards, so it does not collide with the ISO 7573
parts list above the title block. Refusals, all before the first entity: a
malformed code, a cloud region that is not [x0, y0, x1, y1], and any
request for clouds on a backend whose `revcloud` capability is false (the
COM engine — REVCLOUD has no ActiveX member) — that one carries
`capability: "revcloud"`.
"""
from engineering.sheet.revision import add_revision
await ctx.info(f"Revision {rev}: {description}")
return await add_revision(
_backend(ctx),
rev=rev,
description=description,
date=date,
by=by,
clouds=clouds or (),
tags=tags or (),
size=size,
orientation=orientation,
origin=(origin_x, origin_y),
cloud_segment=cloud_segment,
layout=layout or None,
)
@cad_tool(
summary="Read the bill of materials out of the drawing's block references.",
cost="read",
)
@mcp.tool(
annotations={"title": "Sheet: Extract Bill of Materials", "readOnlyHint": True},
tags={"engineering", "sheet", "query"},
)
async def bom_extract(
layer: Annotated[
str, Field(default="", description="Only read INSERTs on this layer; empty reads all.")
] = "",
group_by: Annotated[
str,
Field(
default="designation",
description="Field to group identical parts by: designation, standard, material, "
"or '' for one row per insert.",
),
] = "designation",
columns: Annotated[
list[str] | None,
Field(
default=None,
description="Columns to return; default item, qty, designation, standard, material.",
),
] = None,
limit: Annotated[
int,
Field(
default=0,
ge=0,
description="Stop after this many item records; 0 (the default) reads the whole "
"drawing. `truncated` says whether the cap cut the list.",
),
] = 0,
ctx: Context = None,
) -> dict:
"""Walk the INSERTs and return the ISO 7573 item rows. Never modifies the
drawing. `bom_table` is what draws them.
Each row's source is reported: `xdata` when the ACADMCP_MECH payload
supplied it (what `std_part_insert` writes), `attributes` when the block's
own ATTRIBs did. A block with neither a payload nor a DESIGNATION attribute
is skipped rather than guessed at, and `skipped` counts them.
The whole drawing is read by default — `entity_list`'s 200-entity page is
paged through, not taken as a cap, because an item list that stops at the
200th INSERT prints a wrong QTY rather than a shorter one. Pass `limit` to
cap it deliberately; `truncated` is then true if and only if the cap cut
the list. Refusals: an unknown column or group_by field, named with the
list of the ones there are.
"""
from engineering.sheet.bom import extract_records, rows_from_records
backend = _backend(ctx)
cap = int(limit) or None
# One record past the cap, so `truncated` is measured rather than guessed:
# exactly `cap` records on the drawing is not a truncation.
records = await extract_records(
backend, layer=layer or None, limit=None if cap is None else cap + 1
)
truncated = cap is not None and len(records) > cap
if truncated:
records = records[:cap]
rows = rows_from_records(records, columns=columns, group_by=group_by or None)
return {
"ok": True,
"standard": "ISO 7573",
"rows": [dict(row) | {"handles": list(row["handles"])} for row in rows],
"records": len(records),
"limit": cap,
"truncated": truncated,
"group_by": group_by or None,
}
@cad_tool(summary="Draw the parts list as a real TABLE above the title block.", cost="mutate")
@mcp.tool(
annotations={"title": "Sheet: Draw Parts List", "destructiveHint": False},
tags={"engineering", "sheet"},
)
async def bom_table(
rows: Annotated[
list[dict] | None,
Field(
default=None,
description="Rows to draw; omit to extract them from the drawing first.",
),
] = None,
size: Annotated[str, Field(default="A3", description="Sheet the list is placed on.")] = "A3",
orientation: Annotated[str, Field(default="landscape")] = "landscape",
origin_x: Annotated[float, Field(default=0.0)] = 0.0,
origin_y: Annotated[float, Field(default=0.0)] = 0.0,
at_x: Annotated[
float | None,
Field(default=None, description="Override the anchor X; default is the title block's."),
] = None,
at_y: Annotated[float | None, Field(default=None)] = None,
direction: Annotated[
str,
Field(
default="up",
description="'up' (ISO 7573 on a drawing: heading at the bottom, items ascending) "
"or 'down'.",
),
] = "up",
columns: Annotated[list[str] | None, Field(default=None)] = None,
row_height: Annotated[float, Field(default=7.0, gt=0)] = 7.0,
layout: Annotated[str, Field(default="")] = "",
ctx: Context = None,
) -> dict:
"""Draw the ISO 7573 item list as a real TABLE entity, 180 mm wide (the
title-block width), growing upwards from directly above the title block.
With `direction="up"` the heading row sits against the title block and item
1 is the row above it, so the list extends upwards as parts are added —
which is what ISO 7573 asks for on a drawing. Pass `rows` to draw a list
you already have, or omit it and the tool runs `bom_extract` first.
`representation` reports what was drawn: `native` (a real ACAD_TABLE on the
live engine) or `composite` (rules and MTEXT headlessly, whose `handle` is
the first child). Refusals: no rows to draw, an unknown column, an unknown
direction, a `layout` that does not exist.
"""
from engineering.sheet.bom import (
draw_bom_table,
extract_records,
rows_from_records,
)
from engineering.sheet.titleblock import TB_HEIGHT, titleblock_origin
backend = _backend(ctx)
if rows is None:
rows = list(rows_from_records(await extract_records(backend), columns=columns))
tb_x, tb_y = titleblock_origin(size, orientation=orientation)
anchor = (
origin_x + tb_x if at_x is None else at_x,
origin_y + tb_y + TB_HEIGHT if at_y is None else at_y,
)
await ctx.info(f"Parts list: {len(rows)} rows at {anchor}")
return await draw_bom_table(
backend,
rows,
at=anchor,
direction=direction,
columns=columns,
row_height=row_height,
layout=layout or None,
)
@cad_tool(summary="Add an ISO 6433 balloon linked to its item row.", cost="mutate")
@mcp.tool(
annotations={"title": "Sheet: Add Balloon", "destructiveHint": False},
tags={"engineering", "sheet"},
)
async def balloon_add(
item: Annotated[
int, Field(ge=1, description="Item reference number, as bom_extract numbers it.")
],
x: Annotated[float, Field(description="Balloon centre X (WCS).")],
y: Annotated[float, Field(description="Balloon centre Y (WCS).")],
leader_x: Annotated[float, Field(description="X of the dot on the item.")],
leader_y: Annotated[float, Field(description="Y of the dot on the item.")],
targets: Annotated[
list[str] | None,
Field(
default=None,
description="Handles of the INSERTs this item is; the link a rerun renumbers by.",
),
] = None,
radius: Annotated[
float,
Field(
default=4.0,
gt=0,
description="Balloon radius (mm). The numeral is 1.4x this, so a sheet "
"dimensioned at 3.5 mm wants 5.0 to satisfy ISO 6433.",
),
] = 4.0,
layer: Annotated[str, Field(default="DIM")] = "DIM",
layout: Annotated[str, Field(default="")] = "",
ctx: Context = None,
) -> dict:
"""One ISO 6433 item reference: a numbered balloon, a leader, and a dot on
the item.
The balloon carries an ACADMCP_MECH payload naming its targets, so running
the balloon pass again after the bill of materials is regrouped *renumbers*
the existing balloon (`renumbered: true`) instead of drawing a second one.
Refusals, all before the first entity: an item number below 1, a leader
target inside the balloon, and an item number already used by another
balloon for different targets — named with that balloon's handle.
"""
from engineering.sheet.bom import add_balloon
await ctx.info(f"Balloon {item} at ({x}, {y})")
return await add_balloon(
_backend(ctx),
item=item,
at=(x, y),
leader_to=(leader_x, leader_y),
targets=tuple(targets or ()),
radius=radius,
layer=layer,
layout=layout or None,
)
@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` and `kind` are both null — ActiveX's block interface
carries neither a per-xref insert count nor an overlay indicator, and this
server does not invent them. Headlessly both are measured: `inserts`
counts every insert in the drawing, model space and paper space alike, and
`detach` removes exactly those.
"""
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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------