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
+2 -2
View File
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 206 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> **v1.5 release snapshot:** 209 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
<sub>206 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>209 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
+32
View File
@@ -372,6 +372,38 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"kose kaynagi",
),
),
"centre_marks": ToolAliases(
acad=("CENTERMARK", "CENTERLINE", "DIMCENTER"),
synonyms=(
"centre mark",
"center mark",
"centre line",
"center line of a hole",
"cross at the centre",
"eksen cizgisi",
"merkez isareti",
),
),
"section_line": ToolAliases(
acad=("SECTIONPLANE",),
synonyms=(
"cutting plane line",
"section line",
"section a-a",
"where to cut the part",
"view direction arrows",
"kesit cizgisi",
),
),
"hatch_material": ToolAliases(
synonyms=(
"material hatch",
"hatch as cast iron",
"section hatching for steel",
"hatch pattern for a material",
"malzeme taramasi",
),
),
# ── Dimensions ──────────────────────────────────────────────────────────
"dimension_aligned": ToolAliases(
acad=("DIMALIGNED", "DAL"),
+44 -2
View File
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 206,
"tools": 209,
"resources": 8,
"prompts": 5,
"groups": 23
@@ -15,7 +15,7 @@
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
"engineering": 12,
"engineering": 15,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
@@ -343,6 +343,20 @@
"destructiveHint": true
}
},
{
"name": "centre_marks",
"group": "engineering",
"cost": "mutate",
"summary": "Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.",
"tags": [
"engineering",
"mech"
],
"annotations": {
"title": "Centre Marks (ISO 128-23)",
"destructiveHint": false
}
},
{
"name": "construction_clear",
"group": "premium",
@@ -1592,6 +1606,20 @@
"destructiveHint": false
}
},
{
"name": "hatch_material",
"group": "engineering",
"cost": "mutate",
"summary": "Hatch a region with the ISO 128-50 section pattern for a named material.",
"tags": [
"engineering",
"mech"
],
"annotations": {
"title": "Material Hatch (ISO 128-50)",
"destructiveHint": false
}
},
{
"name": "hatch_set_gradient",
"group": "entity_creation",
@@ -2212,6 +2240,20 @@
"readOnlyHint": true
}
},
{
"name": "section_line",
"group": "engineering",
"cost": "mutate",
"summary": "Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.",
"tags": [
"engineering",
"mech"
],
"annotations": {
"title": "Section Line (ISO 128-40)",
"destructiveHint": false
}
},
{
"name": "selection_filter",
"group": "entity_query",
+407
View File
@@ -0,0 +1,407 @@
"""Centre marks, the cutting-plane line and the material hatch.
Pure geometry: these functions return primitive descriptions from
`engineering.mech.primitives`; the `draw_*` functions are the only async part
and the only part that touches a backend.
SOURCE - centre marks and centre lines: ISO 128-23, "Technical drawings -
General principles of presentation - Lines on construction drawings", which
draws a centre line as a long-dash-dotted line that overruns the feature's
outline by a short amount. The *dash pattern itself is not written here*: the
prims are plain LINE entities on the CENTER layer, whose CENTER linetype
(bootstrapped by `drawing_new`) supplies the standard's dash proportions. That
is deliberate - transcribing a dash table this module cannot verify would put
an invented pattern next to a real one.
SOURCE - the cutting-plane line: ISO 128-40, "Technical drawings - General
principles of presentation - Basic conventions for cuts and sections": a
long-dash-dotted line that is *wide* at its ends and at every change of
direction, an arrow at each end pointing in the direction of viewing, and the
same capital letter at both ends. Line width is a layer property here, so the
wide ends are emitted on the `visible` role (GEOMETRY, 0.50 mm) and the thin
middle on the `center` role (CENTER, 0.25 mm) - the ISO appearance, expressed
in the role vocabulary this repository already has.
DECLARED, not transcribed: ISO 128-40 says the ends are thickened and that
arrows are placed there; it does not dimension them. The end-segment length,
the arrow shaft length, the arrowhead size and the label offset below are this
implementation's stated choices, expressed in text heights.
The material hatch is not authored here at all: it comes from
`engineering.mech.standards.materials.hatch_for`, which carries the ISO 128-50
map and its own refusal.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from engineering.mech.primitives import Line, Poly, Prim, Text
if TYPE_CHECKING: # pragma: no cover - typing only
from backends.base import AutoCADBackend
__all__ = [
"CENTRE_STYLES",
"SECTION_STYLES",
"centre_mark_prims",
"draw_centre_marks",
"draw_material_hatch",
"draw_section_line",
"section_line_prims",
"section_plane",
]
CENTRE_STYLES: tuple[str, ...] = ("mark", "lines")
# The four styles `views.build_view(kind="section", style=...)` accepts; the
# dict this module returns is fed straight to it.
SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved")
# DECLARED layout constants for the cutting-plane line, in text heights.
END_SEGMENT_FACTOR = 2.0 # wide segment at each end
ARROW_SHAFT_FACTOR = 2.0 # arrow shaft length, perpendicular to the plane
ARROW_HEAD_FACTOR = 0.8 # arrowhead length (half-width is 0.35 of it, as elsewhere)
LABEL_OFFSET_FACTOR = 0.7 # gap between the arrow tail and the label
def centre_mark_prims(
center: tuple[float, float],
radius: float,
*,
style: str = "mark",
extension: float = 3.0,
) -> tuple[Prim, ...]:
"""A centre mark or a pair of full centre lines for a circular feature.
`style="mark"` draws the short cross AutoCAD's DIMCEN draws: arms of
`extension` either side of the centre. `style="lines"` draws the two full
centre lines, overrunning the circle by `extension` at each end, which is
ISO 128-23's short overrun of the outline.
"""
if style not in CENTRE_STYLES:
raise ValueError(
f"centre style {style!r} is unknown; use one of {', '.join(CENTRE_STYLES)}."
)
ext = float(extension)
if not math.isfinite(ext) or ext <= 0.0:
raise ValueError(f"extension must be a positive finite number; got {extension!r}.")
cx, cy = float(center[0]), float(center[1])
if style == "mark":
arm = ext
else:
r = float(radius)
if not math.isfinite(r) or r <= 0.0:
raise ValueError(
f"style='lines' needs the feature's radius to know how far to overrun it; "
f"got radius={radius!r}."
)
arm = r + ext
return (
Line((cx - arm, cy), (cx + arm, cy), "center"),
Line((cx, cy - arm), (cx, cy + arm), "center"),
)
def _unit(vx: float, vy: float, what: str) -> tuple[float, float]:
length = math.hypot(vx, vy)
if length <= 1e-12:
raise ValueError(f"{what} must be a non-zero vector; got ({vx:g}, {vy:g}).")
return vx / length, vy / length
def section_line_prims(
p1: tuple[float, float],
p2: tuple[float, float],
*,
label: str = "A",
direction: tuple[float, float] = (0.0, -1.0),
height: float = 5.0,
) -> tuple[Prim, ...]:
"""The ISO 128-40 cutting-plane line: wide ends, arrows, the letter at both ends.
`direction` is the direction of viewing and must not be parallel to the
plane. The arrowhead's tip sits on the end of the cutting-plane line and
its shaft extends away from the part, so the head reads as pointing the way
the section is viewed; the letter goes beyond the tail.
"""
if not str(label).strip():
raise ValueError("a cutting-plane line needs a label; ISO 128-40 letters both ends.")
h = float(height)
if not math.isfinite(h) or h <= 0.0:
raise ValueError(f"height must be a positive finite number; got {height!r}.")
x1, y1 = float(p1[0]), float(p1[1])
x2, y2 = float(p2[0]), float(p2[1])
ux, uy = _unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)")
dx, dy = _unit(float(direction[0]), float(direction[1]), "direction")
if abs(ux * dy - uy * dx) < 1e-6:
raise ValueError(
f"direction ({dx:g}, {dy:g}) is parallel to the cutting plane; the viewing "
"direction must be perpendicular to it."
)
span = math.hypot(x2 - x1, y2 - y1)
end = END_SEGMENT_FACTOR * h
prims: list[Prim] = []
if span <= 2.0 * end + 1e-9:
prims.append(Line((x1, y1), (x2, y2), "visible"))
else:
a = (x1 + ux * end, y1 + uy * end)
b = (x2 - ux * end, y2 - uy * end)
prims.append(Line((x1, y1), a, "visible"))
prims.append(Line(b, (x2, y2), "visible"))
prims.append(Line(a, b, "center"))
shaft = ARROW_SHAFT_FACTOR * h
head = ARROW_HEAD_FACTOR * h
px, py = -dy, dx
text = str(label).strip()
for tip in ((x1, y1), (x2, y2)):
tail = (tip[0] - dx * shaft, tip[1] - dy * shaft)
prims.append(Line(tail, tip, "visible"))
base = (tip[0] - dx * head, tip[1] - dy * head)
prims.append(
Poly(
(
tip,
(base[0] + px * head * 0.35, base[1] + py * head * 0.35),
(base[0] - px * head * 0.35, base[1] - py * head * 0.35),
),
True,
"visible",
)
)
for tip in ((x1, y1), (x2, y2)):
offset = shaft + LABEL_OFFSET_FACTOR * h
prims.append(Text((tip[0] - dx * offset, tip[1] - dy * offset), text, h))
return tuple(prims)
def section_plane(
p1: tuple[float, float],
p2: tuple[float, float],
*,
label: str = "A",
direction: tuple[float, float] = (0.0, -1.0),
style: str = "full",
) -> dict:
"""The plane definition `mech_view_add(kind="section")` consumes.
Exactly the five keys the plan pins as SECTION_PLANE, in that order, with
`direction` normalised to a unit vector. Returning it from the tool that
draws the cutting-plane line is what makes the label on the plan and the
label on the section view the same string by construction.
"""
if style not in SECTION_STYLES:
raise ValueError(
f"section style {style!r} is unknown; use one of {', '.join(SECTION_STYLES)}."
)
text = str(label).strip()
if not text:
raise ValueError("a section plane needs a label; ISO 128-40 letters both ends.")
x1, y1 = float(p1[0]), float(p1[1])
x2, y2 = float(p2[0]), float(p2[1])
_unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)")
dx, dy = _unit(float(direction[0]), float(direction[1]), "direction")
return {
"p1": (x1, y1),
"p2": (x2, y2),
"label": text,
"direction": (dx, dy),
"style": style,
}
# ── the async layer ─────────────────────────────────────────────────────────
_CIRCULAR_TYPES = ("CIRCLE", "ARC")
async def draw_centre_marks(
backend: AutoCADBackend,
*,
handles: list[str] | None = None,
centers: list[list[float]] | None = None,
style: str = "mark",
extension: float = 3.0,
layer: str | None = None,
) -> dict:
"""Centre marks or centre lines for circular features.
With `handles`, the circles' real centre and radius are read back out of
the drawing through `entity_get` - the caller is never asked to restate a
radius it could get wrong. With `centers`, each row is `[x, y]` or
`[x, y, radius]`; `style="lines"` needs the radius, because the overrun is
measured from the outline.
"""
from engineering.mech.annotate import draw_annotation_prims
if not handles and not centers:
raise ValueError("centre_marks needs either handles or centers; both are empty.")
if handles and centers:
raise ValueError("centre_marks takes handles or centers, not both.")
sources: list[dict] = []
if handles:
for handle in handles:
info = await backend.entity_get(handle)
if info.type not in _CIRCULAR_TYPES:
raise ValueError(
f"handle {handle} is a {info.type}; a centre mark needs a circular "
f"feature ({', '.join(_CIRCULAR_TYPES)})."
)
radius = info.properties.get("radius")
if radius is None:
raise ValueError(
f"handle {handle} lies in a plane tilted out of WCS XY, so it has no "
"radius in this frame (capability 'ocs_tilted_plane'); its centre mark "
"cannot be drawn in xy."
)
center = info.properties["center"]
sources.append(
{
"handle": info.handle,
"center": [float(center[0]), float(center[1])],
"radius": float(radius),
}
)
else:
for index, row in enumerate(centers or []):
if len(row) < 2:
raise ValueError(f"centers[{index}] must be [x, y] or [x, y, radius].")
radius = float(row[2]) if len(row) > 2 else None
if style == "lines" and radius is None:
raise ValueError(
f"centers[{index}] has no radius, and style='lines' measures its overrun "
"from the outline; pass [x, y, radius] or use style='mark'."
)
sources.append(
{
"handle": None,
"center": [float(row[0]), float(row[1])],
"radius": radius,
}
)
# Validate every source before the first entity is written.
batches = [
centre_mark_prims(
(source["center"][0], source["center"][1]),
source["radius"] if source["radius"] is not None else 0.0,
style=style,
extension=extension,
)
for source in sources
]
handles_out: list[str] = []
for prims in batches:
drawn = await draw_annotation_prims(backend, prims, layer=layer)
handles_out.extend(drawn["handles"])
return {
"ok": True,
"handles": handles_out,
"count": len(handles_out),
"style": style,
"sources": sources,
}
async def draw_section_line(
backend: AutoCADBackend,
p1: tuple[float, float],
p2: tuple[float, float],
*,
label: str = "A",
direction: tuple[float, float] = (0.0, -1.0),
style: str = "full",
height: float = 5.0,
layer: str | None = None,
) -> dict:
"""Draw the cutting-plane line and hand back the plane the view engine consumes."""
from engineering.mech.annotate import draw_annotation_prims
plane = section_plane(p1, p2, label=label, direction=direction, style=style)
prims = section_line_prims(
plane["p1"],
plane["p2"],
label=plane["label"],
direction=plane["direction"],
height=height,
)
result = await draw_annotation_prims(backend, prims, layer=layer)
result["plane"] = plane
result["label"] = plane["label"]
return result
async def draw_material_hatch(
backend: AutoCADBackend,
*,
boundary: list[list[float]] | None = None,
handles: list[str] | None = None,
material: str = "steel",
scale: float = 1.0,
angle: float | None = None,
layer: str | None = None,
) -> dict:
"""Hatch a closed region with the ISO 128-50 pattern for a material.
`hatch_for` owns the map and its refusal, so an unknown material name is
rejected here before anything is drawn. `scale` multiplies the material's
own pattern scale; `angle` replaces the material's own angle when given.
"""
from engineering.mech.annotate import ensure_annotation_layers
from engineering.mech.primitives import ROLE_LAYER as _ROLE_LAYER
from engineering.mech.standards.materials import hatch_for
spec = hatch_for(material) # refuses an unknown material, naming the list
factor = float(scale)
if not math.isfinite(factor) or factor <= 0.0:
raise ValueError(f"scale must be a positive finite number; got {scale!r}.")
if boundary is None and not handles:
raise ValueError("hatch_material needs either boundary points or handles; both are empty.")
if boundary is not None and handles:
raise ValueError("hatch_material takes boundary or handles, not both.")
if boundary is not None:
points = [[float(p[0]), float(p[1])] for p in boundary]
if len(points) < 3:
raise ValueError(f"a hatch boundary needs at least three points; got {len(points)}.")
else:
loop = await backend.boundary_from_entities(list(handles or []))
if not loop.get("ok"):
raise ValueError(f"hatch_material could not close a loop: {loop.get('error')}")
traced = await backend.entity_get(loop["handle"])
points = [[float(p[0]), float(p[1])] for p in traced.properties["points"]]
pattern_scale = float(spec["scale"]) * factor
pattern_angle = float(spec["angle"]) if angle is None else float(angle)
target = layer or _ROLE_LAYER["hatch"]
# MEASURED on AutoCAD 2026: `msp.AddHatch(...)` + `Evaluate()` puts the
# AcDbHatch in model space, and the next statement - `entity.Layer = "HATCH"`
# inside `_apply_entity_attrs` - raises `('Key not found')` on a drawing whose
# layer table is `['0']`, leaving that hatch orphaned on layer 0. ActiveX never
# creates the layer; `EzdxfBackend._apply_attrs` does, so without this the same
# call succeeds headless and half-writes live. `drawing_open` runs no bootstrap,
# which is exactly when a cut face has no HATCH layer to land on.
created = await ensure_annotation_layers(backend, [target])
info = await backend.entity_create_hatch(
spec["pattern"],
points,
pattern_scale,
pattern_angle,
target,
)
return {
"ok": True,
"handle": info.handle,
"material": material,
"pattern": spec["pattern"],
"scale": pattern_scale,
"angle": pattern_angle,
"points": len(points),
"layer": target,
"layers_created": list(created),
}
+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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+336
View File
@@ -0,0 +1,336 @@
"""Centre marks (ISO 128-23), the cutting-plane line (ISO 128-40) and material hatch.
Pure geometry asserted as emitted primitive tuples; the async layer against a
real ezdxf document through the `backend` fixture.
"""
from __future__ import annotations
import pytest
from engineering.mech.marks import (
CENTRE_STYLES,
centre_mark_prims,
)
from engineering.mech.primitives import Line
pytestmark = pytest.mark.asyncio
EPS = 1e-9
def _flat(points):
"""Flatten a point sequence: pytest.approx refuses nested structures."""
return tuple(float(value) for point in points for value in point)
async def test_a_centre_mark_is_a_small_cross_on_the_centre_role():
prims = centre_mark_prims((10.0, 20.0), 5.0, style="mark", extension=3.0)
assert len(prims) == 2
horizontal, vertical = prims
assert isinstance(horizontal, Line) and isinstance(vertical, Line)
assert horizontal.p1 == pytest.approx((7.0, 20.0), abs=EPS)
assert horizontal.p2 == pytest.approx((13.0, 20.0), abs=EPS)
assert vertical.p1 == pytest.approx((10.0, 17.0), abs=EPS)
assert vertical.p2 == pytest.approx((10.0, 23.0), abs=EPS)
assert horizontal.role == "center" and vertical.role == "center"
async def test_centre_lines_cross_the_circle_and_overrun_it_by_the_extension():
prims = centre_mark_prims((10.0, 20.0), 5.0, style="lines", extension=3.0)
horizontal, vertical = prims
assert horizontal.p1 == pytest.approx((2.0, 20.0), abs=EPS)
assert horizontal.p2 == pytest.approx((18.0, 20.0), abs=EPS)
assert vertical.p1 == pytest.approx((10.0, 12.0), abs=EPS)
assert vertical.p2 == pytest.approx((10.0, 28.0), abs=EPS)
assert horizontal.role == "center" and vertical.role == "center"
async def test_the_style_enum_is_closed_and_a_bad_radius_is_refused():
assert CENTRE_STYLES == ("mark", "lines")
with pytest.raises(ValueError) as excinfo:
centre_mark_prims((0.0, 0.0), 5.0, style="cross")
assert "mark, lines" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
centre_mark_prims((0.0, 0.0), 0.0, style="lines")
assert "radius" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
centre_mark_prims((0.0, 0.0), 5.0, extension=0.0)
assert "extension" in str(excinfo.value)
# ── ISO 128-40 cutting-plane line ───────────────────────────────────────────
from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests
SECTION_STYLES,
section_line_prims,
section_plane,
)
from engineering.mech.primitives import Poly, Text # noqa: E402
async def test_the_cutting_plane_line_is_thick_at_the_ends_and_thin_between():
prims = section_line_prims(
(0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0
)
head_end, tail_end, middle = prims[0], prims[1], prims[2]
assert head_end.p1 == pytest.approx((0.0, 0.0), abs=EPS)
assert head_end.p2 == pytest.approx((10.0, 0.0), abs=EPS)
assert head_end.role == "visible"
assert tail_end.p1 == pytest.approx((90.0, 0.0), abs=EPS)
assert tail_end.p2 == pytest.approx((100.0, 0.0), abs=EPS)
assert tail_end.role == "visible"
assert middle.p1 == pytest.approx((10.0, 0.0), abs=EPS)
assert middle.p2 == pytest.approx((90.0, 0.0), abs=EPS)
assert middle.role == "center"
async def test_a_short_cutting_plane_is_one_wide_segment_with_no_thin_middle():
prims = section_line_prims((0.0, 0.0), (15.0, 0.0), height=5.0)
assert prims[0].p1 == pytest.approx((0.0, 0.0), abs=EPS)
assert prims[0].p2 == pytest.approx((15.0, 0.0), abs=EPS)
assert prims[0].role == "visible"
assert not [p for p in prims if isinstance(p, Line) and p.role == "center"]
async def test_each_end_gets_an_arrow_pointing_the_viewing_direction_and_the_same_letter():
prims = section_line_prims(
(0.0, 0.0), (100.0, 0.0), label="B", direction=(0.0, -1.0), height=5.0
)
shafts = [p for p in prims if isinstance(p, Line) and p.p1[0] == p.p2[0]]
assert len(shafts) == 2
# The shaft comes from outside and the head's tip sits on the line end.
assert shafts[0].p1 == pytest.approx((0.0, 10.0), abs=EPS)
assert shafts[0].p2 == pytest.approx((0.0, 0.0), abs=EPS)
assert shafts[1].p1 == pytest.approx((100.0, 10.0), abs=EPS)
assert shafts[1].p2 == pytest.approx((100.0, 0.0), abs=EPS)
heads = [p for p in prims if isinstance(p, Poly)]
assert len(heads) == 2 and all(h.closed for h in heads)
assert _flat(heads[0].points) == pytest.approx((0.0, 0.0, 1.4, 4.0, -1.4, 4.0), abs=EPS)
labels = [p for p in prims if isinstance(p, Text)]
assert [t.text for t in labels] == ["B", "B"]
assert labels[0].at == pytest.approx((0.0, 13.5), abs=EPS)
assert labels[1].at == pytest.approx((100.0, 13.5), abs=EPS)
assert all(t.height == 5.0 and t.role == "text" for t in labels)
async def test_a_viewing_direction_parallel_to_the_plane_is_refused():
with pytest.raises(ValueError) as excinfo:
section_line_prims((0.0, 0.0), (100.0, 0.0), direction=(1.0, 0.0))
assert "perpendicular" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
section_line_prims((0.0, 0.0), (0.0, 0.0), direction=(0.0, -1.0))
assert "distinct" in str(excinfo.value)
async def test_section_plane_is_exactly_the_dict_the_view_engine_consumes():
plane = section_plane((0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -2.0))
assert plane == {
"p1": (0.0, 0.0),
"p2": (100.0, 0.0),
"label": "A",
"direction": (0.0, -1.0),
"style": "full",
}
assert list(plane) == ["p1", "p2", "label", "direction", "style"]
assert SECTION_STYLES == ("full", "half", "offset", "revolved")
with pytest.raises(ValueError) as excinfo:
section_plane((0.0, 0.0), (10.0, 0.0), style="broken")
assert "full, half, offset, revolved" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
section_plane((0.0, 0.0), (10.0, 0.0), label="")
assert "label" in str(excinfo.value)
# ── the async layer ─────────────────────────────────────────────────────────
from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests
draw_centre_marks,
draw_material_hatch,
draw_section_line,
)
from engineering.mech.primitives import ROLE_LAYER # noqa: E402
from engineering.mech.standards.materials import MATERIALS, hatch_for # noqa: E402
async def test_centre_marks_read_the_circles_real_radius_from_the_drawing(backend):
small = await backend.entity_create_circle(0.0, 0.0, 4.0, "GEOMETRY")
large = await backend.entity_create_circle(50.0, 0.0, 12.0, "GEOMETRY")
result = await draw_centre_marks(
backend, handles=[small.handle, large.handle], style="lines", extension=2.0
)
assert result["ok"] is True and result["count"] == 4
assert result["sources"] == [
{"handle": small.handle, "center": [0.0, 0.0], "radius": 4.0},
{"handle": large.handle, "center": [50.0, 0.0], "radius": 12.0},
]
first = await backend.entity_get(result["handles"][0])
assert first.layer == ROLE_LAYER["center"]
assert first.properties["start"] == pytest.approx([-6.0, 0.0], abs=EPS)
assert first.properties["end"] == pytest.approx([6.0, 0.0], abs=EPS)
third = await backend.entity_get(result["handles"][2])
assert third.properties["start"] == pytest.approx([36.0, 0.0], abs=EPS)
assert third.properties["end"] == pytest.approx([64.0, 0.0], abs=EPS)
async def test_centre_marks_refuse_a_handle_that_is_not_a_circular_feature(backend):
line = await backend.entity_create_line(0.0, 0.0, 10.0, 0.0, 0.0, 0.0, "GEOMETRY")
before = (await backend.analysis_stats())["total_entities"]
with pytest.raises(ValueError) as excinfo:
await draw_centre_marks(backend, handles=[line.handle])
assert line.handle in str(excinfo.value) and "LINE" in str(excinfo.value)
assert (await backend.analysis_stats())["total_entities"] == before
async def test_explicit_centres_are_accepted_and_lines_need_a_radius(backend):
result = await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="mark", extension=3.0)
assert result["count"] == 2 and result["sources"] == [
{"handle": None, "center": [5.0, 5.0], "radius": None}
]
with pytest.raises(ValueError) as excinfo:
await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="lines")
assert "centers[0]" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
await draw_centre_marks(backend)
assert "handles" in str(excinfo.value) and "centers" in str(excinfo.value)
async def test_draw_section_line_returns_the_plane_dict_the_view_engine_consumes(backend):
result = await draw_section_line(
backend, (0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0
)
assert result["ok"] is True
assert result["plane"] == {
"p1": (0.0, 0.0),
"p2": (100.0, 0.0),
"label": "A",
"direction": (0.0, -1.0),
"style": "full",
}
assert list(result["plane"]) == ["p1", "p2", "label", "direction", "style"]
layers = set()
for handle in result["handles"]:
layers.add((await backend.entity_get(handle)).layer)
assert layers == {ROLE_LAYER["visible"], ROLE_LAYER["center"], ROLE_LAYER["text"]}
async def test_material_hatch_uses_the_iso_128_50_map_and_scales_on_top_of_it(backend):
steel = hatch_for("steel")
result = await draw_material_hatch(
backend,
boundary=[[0.0, 0.0], [40.0, 0.0], [40.0, 20.0], [0.0, 20.0]],
material="steel",
scale=2.0,
)
assert result["ok"] is True and result["material"] == "steel"
assert result["pattern"] == steel["pattern"]
assert result["scale"] == pytest.approx(steel["scale"] * 2.0, abs=EPS)
assert result["angle"] == pytest.approx(steel["angle"], abs=EPS)
hatch = backend._doc.entitydb.get(result["handle"])
assert hatch.dxftype() == "HATCH"
assert hatch.dxf.pattern_name == steel["pattern"]
assert hatch.dxf.pattern_scale == pytest.approx(steel["scale"] * 2.0, abs=EPS)
assert hatch.dxf.pattern_angle == pytest.approx(steel["angle"], abs=EPS)
assert (await backend.entity_get(result["handle"])).layer == ROLE_LAYER["hatch"]
# An explicit angle overrides the material's own.
turned = await draw_material_hatch(
backend,
boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]],
material="steel",
angle=90.0,
)
assert turned["angle"] == pytest.approx(90.0, abs=EPS)
async def test_an_unknown_material_is_refused_with_the_list_before_any_write(backend):
assert "steel" in MATERIALS
before = (await backend.analysis_stats())["total_entities"]
with pytest.raises(ValueError):
await draw_material_hatch(
backend,
boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]],
material="unobtainium",
)
assert (await backend.analysis_stats())["total_entities"] == before
async def test_a_boundary_that_is_not_a_closed_polygon_is_refused(backend):
with pytest.raises(ValueError) as excinfo:
await draw_material_hatch(backend, boundary=[[0.0, 0.0], [10.0, 0.0]], material="steel")
assert "at least three" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
await draw_material_hatch(backend, material="steel")
assert "boundary" in str(excinfo.value) and "handles" in str(excinfo.value)
# ── the HATCH layer must exist before the hatch is created (live) ────────────
import types # noqa: E402
class _StrictLayerHatchBackend:
"""A backend that refuses a hatch on a layer the drawing does not have.
MEASURED on AutoCAD 2026 (probe: GetActiveObject, `Documents.Add()`, a
scratch document whose layer table is `['0']`, `doc.Close(False)` in a
`finally:`): `msp.AddHatch(0, "ANSI31", False)` + `AppendOuterLoop` +
`Evaluate()` leaves the AcDbHatch in model space, and the very next
statement `hatch.Layer = "HATCH"` raises
`com_error (-2147352567, ..., 'Key not found', ..., -2145386476)`. Model
space then still reports `[('AcDbHatch', '0')]` and the layer table is
still `['0']` - the refusal leaves an orphan hatch behind. After
`doc.Layers.Add("HATCH")` the same assignment succeeds. `ComBackend`
reaches that assignment through `_apply_entity_attrs`, which is the one
line `entity.Layer = layer`, so this is the hatch spelling of the failure
`ensure_annotation_layers` already fixed for the annotation symbols.
The fake models that measured refusal and nothing else: every member below
exists on the real `AutoCADBackend` with this signature.
"""
def __init__(self, layers=("0",)):
self.layers = {name.lower(): name for name in layers}
self.calls: list[tuple] = []
async def layer_list(self):
self.calls.append(("layer_list",))
return [types.SimpleNamespace(name=name) for name in self.layers.values()]
async def layer_create(self, name, color=7, linetype="Continuous", lineweight=-3):
self.calls.append(("layer_create", name, color, linetype, lineweight))
self.layers[name.lower()] = name
return types.SimpleNamespace(name=name)
async def entity_create_hatch(
self, pattern, boundary_points, scale=1.0, angle=0.0, layer=None, color=None
):
if layer is not None and layer.lower() not in self.layers:
raise RuntimeError(f"AutoCAD COM error (-0x7ffdfff7) ('Key not found'): {layer}")
self.calls.append(("HATCH", layer, pattern))
return types.SimpleNamespace(handle="2F")
async def test_the_hatch_layer_is_created_before_the_hatch_is():
backend = _StrictLayerHatchBackend()
result = await draw_material_hatch(
backend,
boundary=[[0.0, 0.0], [40.0, 0.0], [40.0, 20.0], [0.0, 20.0]],
material="steel",
)
assert result["ok"] is True and result["handle"] == "2F"
assert result["layers_created"] == [ROLE_LAYER["hatch"]]
# The layer is created strictly before the hatch entity.
kinds = [call[0] for call in backend.calls]
assert kinds.index("layer_create") < kinds.index("HATCH")
# ENGINEERING_LAYERS' own definition, not a bare default.
create = next(c for c in backend.calls if c[0] == "layer_create")
assert create[1] == ROLE_LAYER["hatch"]
async def test_a_drawing_that_already_has_the_hatch_layer_is_not_rewritten():
backend = _StrictLayerHatchBackend(layers=("0", "hatch"))
result = await draw_material_hatch(
backend, boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], material="steel"
)
assert result["layers_created"] == []
assert not [c for c in backend.calls if c[0] == "layer_create"]
+7 -6
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (206 tools, 23 groups).
"""Frozen snapshot of the surface (209 tools, 23 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -531,9 +531,10 @@ async def test_tool_group_sizes_are_unchanged():
since the snapshot was taken.
`engineering` moved 10 -> 12 when v1.6's SECTION 23 opened with
`surface_texture` / `weld_symbol`; both are tagged `engineering` and `mech`,
and `mech` is not in `_GROUP_TAG_PRIORITY`, so they file under
`engineering` rather than opening a group of their own.
`surface_texture` / `weld_symbol`, then 12 -> 15 when `centre_marks`,
`section_line` and `hatch_material` completed it; all five are tagged
`engineering` and `mech`, and `mech` is not in `_GROUP_TAG_PRIORITY`, so
they file under `engineering` rather than opening a group of their own.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -543,7 +544,7 @@ async def test_tool_group_sizes_are_unchanged():
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
"engineering": 12,
"engineering": 15,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
@@ -561,4 +562,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 206
assert sum(sizes.values()) == 209