diff --git a/README.md b/README.md
index ce39366..22f0776 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **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 |
-206 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+209 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**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.
diff --git a/discovery/aliases.py b/discovery/aliases.py
index 18ca298..a225c53 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -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"),
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index cdaa104..268e2ed 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -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",
diff --git a/engineering/mech/marks.py b/engineering/mech/marks.py
new file mode 100644
index 0000000..a4eff9a
--- /dev/null
+++ b/engineering/mech/marks.py
@@ -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),
+ }
diff --git a/server.py b/server.py
index 85d820b..a9900df 100644
--- a/server.py
+++ b/server.py
@@ -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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/test_mech_marks.py b/tests/test_mech_marks.py
new file mode 100644
index 0000000..63625cc
--- /dev/null
+++ b/tests/test_mech_marks.py
@@ -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"]
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index d65fc77..2fc2533 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -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