feat(mech): SECTION 23 completes with centre_marks, section_line, hatch_material

centre_marks draws ISO 128-23 centre marks or full centre lines and reads
each circle's real centre and radius back out of the drawing through
entity_get instead of being told them - a radius restated from memory is
the silent wrong number this repository keeps refusing to ship. A handle
that is not a CIRCLE or ARC, and a circle in a plane tilted out of WCS XY
(which has no radius in this frame), are both refused by handle before
anything is written. The dash proportions are not transcribed here: the
prims are plain lines on CENTER, whose linetype already carries them.

section_line draws the ISO 128-40 cutting-plane line - wide segments at
the ends on GEOMETRY, the thin long-dash-dot middle on CENTER, an arrow at
each end whose head sits on the line end and points the way the section is
viewed, the same letter beyond both arrows - and returns the five-key
plane dict {p1, p2, label, direction, style} that mech_view_add(kind=
"section") consumes, so the label on the plan and the label on the view
are the same string by construction. A viewing direction parallel to the
plane is refused rather than drawn.

hatch_material hatches through standards.materials.hatch_for, so the
ISO 128-50 map and its refusal stay in one place; scale multiplies the
material's own pattern scale and angle replaces its angle. An unknown
material is refused with the list before any entity is written.

DEVIATION - one defect in the plan's own draw_material_hatch, fixed here
with two tests that fail against the plan's code. It called
entity_create_hatch with ROLE_LAYER["hatch"] without creating that layer,
the same omission review found in Task 10's draw_annotation_prims.
MEASURED live on AutoCAD 2026 (probe: GetActiveObject, Documents.Add(),
doc.Close(False) in a finally): on a scratch document whose layer table is
['0'], msp.AddHatch(0, "ANSI31", False) + AppendOuterLoop + Evaluate()
leaves the AcDbHatch in model space, and the very next statement
hatch.Layer = "HATCH" - which is the one line _apply_entity_attrs runs -
raises com_error (-2147352567, ..., 'Key not found', ..., -2145386476);
model space then still reports [('AcDbHatch', '0')] and the layer table is
still ['0'], so the refusal leaves an orphan hatch on layer 0. After
doc.Layers.Add("HATCH") the same assignment succeeds. drawing_open runs no
bootstrap, which is exactly when a cut face has no HATCH layer to land on.
draw_material_hatch now calls ensure_annotation_layers before creating the
hatch and reports layer / layers_created, as draw_annotation_prims does.
The whole task re-measured live afterwards through ComBackend on a fresh
scratch document (layer table ['0']): hatch_material -> {'ok': True,
'handle': '82', 'pattern': 'ANSI31', 'layer': 'HATCH', 'layers_created':
['HATCH']} with the entity read back as HATCH on HATCH; centre_marks on a
live CIRCLE r=12.5 -> sources [{'handle': '84', 'center': [80.0, 0.0],
'radius': 12.5}]; section_line -> 9 entities and the five-key plane; layer
table after ['0', 'CENTER', 'GEOMETRY', 'HATCH', 'TEXT']; document closed.

tests/test_mech_marks.py: 17 passed. Gates: ruff check clean, inventory
regenerated to 209 tools, test_tool_registry (engineering 12 -> 15, total
206 -> 209), test_discovery_aliases, test_tool_inventory,
test_capability_contract and test_release_consistency all pass. Full
suite: 3343 passed, the 6 pre-existing failures verified identical with
this change stashed, and
test_release_consistency.py::test_document_numbers_match_the_tree
deselected as Global Constraints instructs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 19:25:13 +03:00
co-authored by Claude Opus 5
parent c607e75b75
commit 64a57d74a0
7 changed files with 989 additions and 11 deletions
+161 -1
View File
@@ -8124,7 +8124,7 @@ async def drawing_properties_set(
# ---------------------------------------------------------------------------
# ── SECTION 23: Mechanical Annotation (2 tools) ─────────────────────────────
# ── SECTION 23: Mechanical Annotation (5 tools) ─────────────────────────────
# ---------------------------------------------------------------------------
# ISO annotation symbols composed from LINE / ARC / CIRCLE / LWPOLYLINE / TEXT,
# so the same symbol lands on COM and ezdxf. The standards tables live in
@@ -8304,6 +8304,166 @@ async def weld_symbol(
)
@cad_tool(
summary="Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Centre Marks (ISO 128-23)", "destructiveHint": False},
tags={"engineering", "mech"},
)
async def centre_marks(
handles: Annotated[
list[str] | None,
"Handles of CIRCLEs/ARCs; their real centre and radius are read from the drawing.",
] = None,
centers: Annotated[
list[list[float]] | None,
"Explicit centres as [x, y] or [x, y, radius]; style='lines' needs the radius.",
] = None,
style: Annotated[
str,
Field(
default="mark",
description="mark (short cross at the centre) | lines (full centre lines).",
),
] = "mark",
extension: Annotated[
float,
Field(
default=3.0,
gt=0,
description="Arm length (mark) or overrun past the outline (lines), mm.",
),
] = 3.0,
layer: Annotated[str | None, "Override the CENTER role layer."] = None,
ctx: Context = None,
) -> dict:
"""Centre marks on the CENTER layer, whose linetype carries ISO 128-23's dash pattern.
Pass `handles` and the radii are read back with `entity_get` rather than
restated — a radius from memory is the classic silent wrong number.
Refusals, all before the first entity is written: neither `handles` nor
`centers` given (or both), a handle that is not a CIRCLE or ARC, a circle
in a plane tilted out of WCS XY (capability `ocs_tilted_plane`, so it has
no radius in this frame), a `style` outside mark/lines, a non-positive
`extension`, and `style='lines'` on a centre given without a radius.
"""
from engineering.mech.marks import draw_centre_marks
await ctx.debug(f"Centre marks style={style}")
return await draw_centre_marks(
_backend(ctx),
handles=handles,
centers=centers,
style=style,
extension=extension,
layer=layer,
)
@cad_tool(
summary="Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Section Line (ISO 128-40)", "destructiveHint": False},
tags={"engineering", "mech"},
)
async def section_line(
x1: Annotated[float, "Cutting plane start X (WCS)."],
y1: Annotated[float, "Cutting plane start Y (WCS)."],
x2: Annotated[float, "Cutting plane end X (WCS)."],
y2: Annotated[float, "Cutting plane end Y (WCS)."],
label: Annotated[str, "Capital letter drawn at both ends, e.g. 'A'."] = "A",
direction: Annotated[
list[float] | None,
"Viewing direction as [dx, dy]; must not be parallel to the cutting plane.",
] = None,
style: Annotated[
str, "full | half | offset | revolved — carried into the returned plane."
] = "full",
height: Annotated[float, Field(default=5.0, gt=0, description="Label height (mm).")] = 5.0,
layer: Annotated[str | None, "Override the role layers (GEOMETRY/CENTER/TEXT)."] = None,
ctx: Context = None,
) -> dict:
"""Draw the cutting-plane line and hand back the plane a section view consumes.
Wide segments at the ends on GEOMETRY, the thin long-dash-dot middle on
CENTER, an arrow at each end whose head sits on the line end and points the
way the section is viewed, and the same letter beyond both arrows.
`payload["plane"]` is exactly `{p1, p2, label, direction, style}` — feed it
to the section view so the label on the plan and the label on the view
cannot disagree. Refusals, before any entity is written: `p1 == p2`, a zero
or parallel viewing direction, an empty `label`, a `style` outside
full/half/offset/revolved, and a non-positive `height`.
"""
from engineering.mech.marks import draw_section_line
await ctx.debug(f"Section line {label} ({x1},{y1})-({x2},{y2})")
return await draw_section_line(
_backend(ctx),
(x1, y1),
(x2, y2),
label=label,
direction=tuple(direction) if direction else (0.0, -1.0),
style=style,
height=height,
layer=layer,
)
@cad_tool(
summary="Hatch a region with the ISO 128-50 section pattern for a named material.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Material Hatch (ISO 128-50)", "destructiveHint": False},
tags={"engineering", "mech"},
)
async def hatch_material(
material: Annotated[str, "Material name; an unknown one is refused with the list."] = "steel",
boundary: Annotated[
list[list[float]] | None, "Closed boundary as [[x, y], ...] — at least three points."
] = None,
handles: Annotated[
list[str] | None, "Entities to chain into one closed loop instead of a boundary."
] = None,
scale: Annotated[
float,
Field(default=1.0, gt=0, description="Multiplies the material's own pattern scale."),
] = 1.0,
angle: Annotated[
float | None, "Replaces the material's own pattern angle (degrees) when given."
] = None,
layer: Annotated[str | None, "Override the HATCH role layer."] = None,
ctx: Context = None,
) -> dict:
"""Section-hatch a cut face with the pattern ISO 128-50 gives its material.
Refusals: an unknown material (named, with the full list, before anything
is drawn), neither `boundary` nor `handles` (or both), a boundary with
fewer than three points, and a non-positive `scale`. With `handles` the
loop is chained by `boundary_from_entities`, which the COM engine refuses
(capability `boundary_trace`) — pass `boundary` on a live seat, or run
headlessly. The HATCH layer is created first when the drawing lacks it:
ActiveX refuses an entity on an absent layer and would otherwise leave the
hatch orphaned on layer 0.
"""
from engineering.mech.marks import draw_material_hatch
await ctx.debug(f"Material hatch {material}")
return await draw_material_hatch(
_backend(ctx),
boundary=boundary,
handles=handles,
material=material,
scale=scale,
angle=angle,
layer=layer,
)
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------