diff --git a/README.md b/README.md
index 0173421..d112a0f 100644
--- a/README.md
+++ b/README.md
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **v1.5 release snapshot:** 213 tools · 8 resources · 5 prompt templates · 3284 collected tests.
-> 213 is the **registered** count; a default install advertises 208 over `tools/list`,
+> **v1.5 release snapshot:** 218 tools · 8 resources · 5 prompt templates · 3284 collected tests.
+> 218 is the **registered** count; a default install advertises 213 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -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 |
-213 tools in 24 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.
+218 tools in 24 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 370606c..01f5073 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -91,6 +91,10 @@ class ToolAliases:
# OPTIONS -- one dialog both reads and writes a preference; the server
# splits read (system_preferences_get) and write (system_preferences_set),
# the SETVAR precedent.
+# SECTIONPLANE -- the section a drafter asks for is either a whole section
+# view of a drawn part (mech_view_add) or the cutting-plane line and its
+# labels on an existing view (section_line); tracks B+G groups M and A
+# each claimed it, and a drafter typing it could mean either.
#
# Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
@@ -113,6 +117,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"PLOT",
"QSELECT",
"SAVEAS",
+ "SECTIONPLANE",
"SETVAR",
"STYLE",
"UCS",
@@ -345,6 +350,65 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"true position",
),
),
+ # ── Mechanical annotation (ISO 21920-1 / ISO 2553) ──────────────────────
+ "surface_texture": ToolAliases(
+ synonyms=(
+ "surface finish",
+ "surface roughness",
+ "surface texture symbol",
+ "roughness symbol",
+ "ra value",
+ "rz value",
+ "machining symbol",
+ "yuzey puruzlulugu",
+ "yuzey isleme sembolu",
+ ),
+ ),
+ "weld_symbol": ToolAliases(
+ synonyms=(
+ "weld symbol",
+ "welding symbol",
+ "fillet weld",
+ "butt weld",
+ "weld callout",
+ "field weld",
+ "weld all around",
+ "kaynak sembolu",
+ "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 9c2c0d8..400eda3 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": 213,
+ "tools": 218,
"resources": 8,
"prompts": 5,
"groups": 24
@@ -22,7 +22,7 @@
"environment": 22,
"layers": 14,
"layouts": 12,
- "mech": 9,
+ "mech": 14,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -344,6 +344,20 @@
"destructiveHint": true
}
},
+ {
+ "name": "centre_marks",
+ "group": "mech",
+ "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",
@@ -1593,6 +1607,20 @@
"destructiveHint": false
}
},
+ {
+ "name": "hatch_material",
+ "group": "mech",
+ "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",
@@ -2297,6 +2325,20 @@
"readOnlyHint": true
}
},
+ {
+ "name": "section_line",
+ "group": "mech",
+ "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",
@@ -2460,6 +2502,20 @@
"readOnlyHint": true
}
},
+ {
+ "name": "surface_texture",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Draw an ISO 21920-1 surface-texture symbol: Ra/Rz, process, lay, allowance.",
+ "tags": [
+ "engineering",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Surface Texture Symbol (ISO 21920-1)",
+ "destructiveHint": false
+ }
+ },
{
"name": "system_about",
"group": "system",
@@ -3030,6 +3086,20 @@
"title": "Set Viewport Scale",
"destructiveHint": false
}
+ },
+ {
+ "name": "weld_symbol",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Draw an ISO 2553 weld symbol: reference line, identification line, size and pitch.",
+ "tags": [
+ "engineering",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Weld Symbol (ISO 2553)",
+ "destructiveHint": false
+ }
}
],
"resources": [
diff --git a/engineering/mech/annotate.py b/engineering/mech/annotate.py
new file mode 100644
index 0000000..9b1fb63
--- /dev/null
+++ b/engineering/mech/annotate.py
@@ -0,0 +1,666 @@
+"""ISO annotation symbols: surface texture (ISO 21920-1 / ISO 1302) and welds (ISO 2553).
+
+Pure geometry. Every function here returns primitive *descriptions* from
+`engineering.mech.primitives` in a symbol-local frame whose origin is the point
+the leader attaches to; `draw_*` is the only async part and the only part that
+touches a backend. That split is what lets a test assert the emitted tuples
+instead of a screenshot.
+
+SOURCE - surface-texture symbol proportions: ISO 1302:2002, "Geometrical
+Product Specifications (GPS) - Indication of surface texture in technical
+product documentation", the table of graphical-symbol dimensions (text height
+h -> line width d', short-leg height H1, minimum overall height H2). The seven
+rows below are transcribed from it. ISO 21920-1:2021 superseded ISO 1302
+without changing the graphical symbol, so the same proportions serve both
+designations. The clause/table *number* is deliberately not quoted: the values
+are what this implementer can state, the numbering is not, and a fabricated
+citation is worse than none. A text height outside the seven rows is refused
+by name - never interpolated.
+
+SOURCE - direction-of-lay symbols: ISO 1302:2002, the seven symbols for the
+direction of lay (=, perpendicular, X, M, C, R, P). A lay name outside those
+seven is refused with the list.
+
+SOURCE - weld symbols: ISO 2553:2019, "Welding and allied processes -
+Symbolic representation on drawings - Welded joints", elementary weld symbols,
+plus its rules for the reference line, the dashed identification line, the
+size written before the symbol, the length and pitch written after it, the
+field-weld flag and the all-around circle at the kink, and the tail carrying
+the process reference. SIX elementary symbols are transcribed here: square (I),
+single-V, single-bevel, single-U, single-J and fillet. The rest of the
+elementary table - spot, seam, plug/slot, backing run, surfacing, edge,
+steep-flanked V and bevel, fold joint, inclined joint - is NOT shipped,
+because their drawn form could not be verified; a weld kind outside the six is
+refused with the list of the six. A narrow catalogue is merely narrow; a
+guessed weld symbol is a wrong instruction to a welder.
+
+DECLARED, not transcribed. Neither standard dimensions these, so they are this
+implementation's stated choices and are named as such wherever they are used:
+the all-around circle radius, the field-weld flag, the identification line's
+dash pattern and its offset from the reference line, the text line pitch and
+the character-width estimate used to size the extension line and lay out the
+weld annotation.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Iterable
+from typing import TYPE_CHECKING
+
+from engineering.layers import ENGINEERING_LAYERS
+from engineering.mech.primitives import (
+ ROLE_LAYER,
+ Arc,
+ Circle,
+ Line,
+ Poly,
+ Prim,
+ Text,
+ rotate,
+ translate,
+)
+
+if TYPE_CHECKING: # pragma: no cover - typing only
+ from backends.base import AutoCADBackend
+
+__all__ = [
+ "ISO1302_SYMBOL_PROPORTIONS",
+ "LAY_SYMBOLS",
+ "MACHINING_KINDS",
+ "SURFACE_HEIGHTS",
+ "SURFACE_STANDARDS",
+ "WELD_KINDS",
+ "WELD_SIDES",
+ "draw_annotation_prims",
+ "draw_surface_texture",
+ "draw_weld_symbol",
+ "ensure_annotation_layers",
+ "leader_prims",
+ "surface_texture_prims",
+ "symbol_proportions",
+ "weld_symbol_prims",
+]
+
+# ── ISO 1302:2002 graphical-symbol dimensions (transcribed) ─────────────────
+# text height h -> (line width d', short-leg height H1, minimum height H2), mm.
+ISO1302_SYMBOL_PROPORTIONS: dict[float, tuple[float, float, float]] = {
+ 2.5: (0.25, 3.5, 7.5),
+ 3.5: (0.35, 5.0, 10.5),
+ 5.0: (0.5, 7.0, 15.0),
+ 7.0: (0.7, 10.0, 21.0),
+ 10.0: (1.0, 14.0, 30.0),
+ 14.0: (1.4, 20.0, 42.0),
+ 20.0: (2.0, 28.0, 60.0),
+}
+
+SURFACE_HEIGHTS: tuple[float, ...] = tuple(sorted(ISO1302_SYMBOL_PROPORTIONS))
+
+# ISO 21920-1:2021 is the current designation; ISO 1302 is accepted as an alias
+# for drawings still issued under the older one. The geometry is identical.
+SURFACE_STANDARDS: tuple[str, ...] = ("ISO 21920-1", "ISO 1302")
+
+# ISO 1302:2002 direction-of-lay symbols (transcribed).
+LAY_SYMBOLS: dict[str, str] = {
+ "parallel": "=",
+ "perpendicular": "⊥",
+ "crossed": "X",
+ "multidirectional": "M",
+ "circular": "C",
+ "radial": "R",
+ "particulate": "P",
+}
+
+MACHINING_KINDS: tuple[str, ...] = ("any", "required", "prohibited")
+
+# DECLARED layout constants (not standard values).
+ALL_AROUND_RADIUS_FACTOR = 0.35 # all-around circle radius, in text heights
+TEXT_GAP_FACTOR = 0.35 # gap between the extension line and the nearest text
+TEXT_PITCH_FACTOR = 1.5 # line pitch for stacked annotation text
+CHAR_WIDTH_FACTOR = 0.62 # mean character advance, in text heights
+
+# tan(30 deg): the horizontal run of a 60 deg leg per unit of rise.
+_LEG_RUN = 1.0 / math.sqrt(3.0)
+
+
+def symbol_proportions(height: float) -> tuple[float, float, float]:
+ """(d', H1, H2) for a text height, straight out of the ISO 1302 table.
+
+ A height the standard does not tabulate is refused: interpolating would
+ invent a proportion the standard never published.
+ """
+ key = float(height)
+ if key not in ISO1302_SYMBOL_PROPORTIONS:
+ listed = ", ".join(f"{h:g}" for h in SURFACE_HEIGHTS)
+ raise ValueError(
+ f"ISO 1302 dimensions the surface-texture symbol only for text heights "
+ f"{listed} mm; {key:g} mm is outside the table and is not interpolated."
+ )
+ return ISO1302_SYMBOL_PROPORTIONS[key]
+
+
+def surface_texture_prims(
+ *,
+ ra: float | None = None,
+ rz: float | None = None,
+ process: str | None = None,
+ lay: str | None = None,
+ machining: str = "any",
+ all_around: bool = False,
+ height: float = 3.5,
+ allowance: float | None = None,
+ standard: str = "ISO 21920-1",
+) -> tuple[Prim, ...]:
+ """The surface-texture symbol in a symbol-local frame, apex at the origin.
+
+ The apex is the point a leader attaches to. The short leg runs up-left and
+ the long leg up-right, both at 60 degrees to the horizontal, so the
+ included angle is 60 degrees. `machining="required"` closes the vee with
+ the bar at H1; `machining="prohibited"` inscribes the circle in the vee.
+ """
+ if standard not in SURFACE_STANDARDS:
+ raise ValueError(
+ f"surface texture standard {standard!r} is unknown; use one of "
+ f"{', '.join(SURFACE_STANDARDS)}."
+ )
+ if machining not in MACHINING_KINDS:
+ raise ValueError(
+ f"machining {machining!r} is unknown; use one of {', '.join(MACHINING_KINDS)}."
+ )
+ if lay is not None and lay not in LAY_SYMBOLS:
+ raise ValueError(
+ f"lay {lay!r} is not an ISO 1302 direction of lay; use one of "
+ f"{', '.join(sorted(LAY_SYMBOLS))}."
+ )
+ _width, h1, h2 = symbol_proportions(height)
+ h = float(height)
+ short_top = (-h1 * _LEG_RUN, h1)
+ long_top = (h2 * _LEG_RUN, h2)
+
+ prims: list[Prim] = [
+ Line((0.0, 0.0), short_top, "dim"),
+ Line((0.0, 0.0), long_top, "dim"),
+ ]
+ if machining == "required":
+ prims.append(Line(short_top, (h1 * _LEG_RUN, h1), "dim"))
+ elif machining == "prohibited":
+ # Inscribed circle of the 60 deg vee: tangent to both legs and to the
+ # bar height H1, so its centre sits at 2*H1/3 with radius H1/3.
+ prims.append(Circle((0.0, 2.0 * h1 / 3.0), h1 / 3.0, "dim"))
+
+ above: list[str] = []
+ if ra is not None:
+ above.append(f"Ra {float(ra):g}")
+ if rz is not None:
+ above.append(f"Rz {float(rz):g}")
+ if process:
+ above.append(str(process))
+ below: list[str] = []
+ if lay is not None:
+ below.append(LAY_SYMBOLS[lay])
+ if allowance is not None:
+ below.append(f"{float(allowance):g}")
+ below_text = " ".join(below)
+
+ widest = max([len(text) for text in above] + [len(below_text)])
+ extension = max(2.0 * h, CHAR_WIDTH_FACTOR * h * widest + h)
+ prims.append(Line(long_top, (long_top[0] + extension, h2), "dim"))
+
+ x_text = long_top[0] + 0.5 * h
+ for index, line in enumerate(above):
+ prims.append(
+ Text((x_text, h2 + TEXT_GAP_FACTOR * h + index * TEXT_PITCH_FACTOR * h), line, h)
+ )
+ if below_text:
+ prims.append(Text((x_text, h2 - TEXT_GAP_FACTOR * h - h), below_text, h))
+ if all_around:
+ # DECLARED: the standard shows the circle at the kink between the long
+ # leg and the extension line but does not dimension it.
+ prims.append(Circle(long_top, ALL_AROUND_RADIUS_FACTOR * h, "dim"))
+ return tuple(prims)
+
+
+# ── ISO 2553:2019 weld symbols ──────────────────────────────────────────────
+
+WELD_KINDS: tuple[str, ...] = ("square", "v", "bevel", "u", "j", "fillet")
+WELD_SIDES: tuple[str, ...] = ("arrow", "other", "both")
+
+# Symbol width per kind, in text heights: the half symbols (bevel, J) are half
+# as wide as the full ones they are half of.
+_WELD_WIDTH_FACTOR: dict[str, float] = {
+ "square": 1.0,
+ "v": 1.0,
+ "bevel": 0.5,
+ "u": 1.0,
+ "j": 0.5,
+ "fillet": 1.0,
+}
+
+# DECLARED layout constants for the weld annotation (ISO 2553 fixes none of
+# these numerically; it fixes only what goes where).
+IDENT_OFFSET_FACTOR = 0.4 # identification line offset below the reference line
+IDENT_DASH_FACTOR = 0.8 # dash length of the identification line
+IDENT_GAP_FACTOR = 0.4 # gap between its dashes
+FLAG_POLE_FACTOR = 1.4 # field-weld flag pole height
+KINK_CIRCLE_FACTOR = 0.4 # all-around circle radius at the kink
+TAIL_FORK_FACTOR = 0.8 # tail fork length
+
+
+def _weld_size_text(kind: str, size) -> str:
+ """The text written before the symbol.
+
+ A string passes through verbatim, so `z7` (leg length) stays available; a
+ number on a fillet takes the `a` prefix, ISO 2553's design throat
+ thickness, because a bare number on a fillet weld is ambiguous.
+ """
+ if size is None:
+ return ""
+ if isinstance(size, str):
+ return size.strip()
+ if kind == "fillet":
+ return f"a{float(size):g}"
+ return f"{float(size):g}"
+
+
+def _elementary_prims(kind: str, x0: float, y0: float, sense: float, h: float) -> tuple[Prim, ...]:
+ """One elementary symbol standing on (x0, y0), growing in `sense` (+1/-1)."""
+ s = float(sense)
+ if kind == "square":
+ return (
+ Line((x0 + 0.3 * h, y0), (x0 + 0.3 * h, y0 + s * h), "dim"),
+ Line((x0 + 0.7 * h, y0), (x0 + 0.7 * h, y0 + s * h), "dim"),
+ )
+ if kind == "v":
+ return (Poly(((x0, y0 + s * h), (x0 + 0.5 * h, y0), (x0 + h, y0 + s * h)), False, "dim"),)
+ if kind == "bevel":
+ return (
+ Line((x0, y0), (x0, y0 + s * h), "dim"),
+ Line((x0, y0), (x0 + 0.5 * h, y0 + s * h), "dim"),
+ )
+ if kind == "u":
+ cy = y0 + s * 0.5 * h
+ start, end = (180.0, 360.0) if s > 0 else (0.0, 180.0)
+ return (
+ Arc((x0 + 0.5 * h, cy), 0.5 * h, start, end, "dim"),
+ Line((x0, cy), (x0, y0 + s * h), "dim"),
+ Line((x0 + h, cy), (x0 + h, y0 + s * h), "dim"),
+ )
+ if kind == "j":
+ cy = y0 + s * 0.5 * h
+ start, end = (270.0, 360.0) if s > 0 else (0.0, 90.0)
+ return (
+ Arc((x0, cy), 0.5 * h, start, end, "dim"),
+ Line((x0 + 0.5 * h, cy), (x0 + 0.5 * h, y0 + s * h), "dim"),
+ Line((x0, y0), (x0, y0 + s * h), "dim"),
+ )
+ # fillet
+ return (Poly(((x0, y0), (x0, y0 + s * h), (x0 + h, y0)), True, "dim"),)
+
+
+def weld_symbol_prims(
+ *,
+ kind: str,
+ size=None,
+ length: float | None = None,
+ pitch: float | None = None,
+ side: str = "arrow",
+ field_weld: bool = False,
+ all_around: bool = False,
+ process: str | None = None,
+ height: float = 3.5,
+) -> tuple[Prim, ...]:
+ """The ISO 2553 weld annotation in a local frame, kink at the origin.
+
+ The kink is where the arrow line meets the reference line, so it is the
+ point a leader attaches to. The continuous reference line runs in +X; the
+ dashed identification line runs parallel below it and is omitted for a
+ symmetrical weld (`side="both"`), which ISO 2553:2019 permits. The
+ arrow-side symbol stands on the reference line and the other-side symbol
+ hangs below the identification line - except for a symmetrical weld, where
+ ISO 2553 places the two elementary symbols back-to-back on the continuous
+ reference line itself (an X butt weld is one symbol, not two detached
+ halves), so the lower one hangs from y = 0 and the pair is symmetric about
+ the reference line.
+
+ The size and the length/pitch are written beside the symbol they belong
+ to - size to its left, length to its right - which for an other-side weld
+ means beside the identification line, not above the reference line: a
+ throat dimension written on the arrow side of a joint with no arrow-side
+ weld tells a welder the wrong thing.
+ """
+ if kind not in WELD_KINDS:
+ raise ValueError(
+ f"weld kind {kind!r} is not drawn by this server. The ISO 2553 elementary "
+ f"symbols transcribed here are: {', '.join(WELD_KINDS)}. The rest of the "
+ "elementary table is not shipped rather than guessed."
+ )
+ if side not in WELD_SIDES:
+ raise ValueError(f"side {side!r} is unknown; use one of {', '.join(WELD_SIDES)}.")
+ 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}.")
+ if pitch is not None and length is None:
+ raise ValueError(
+ "pitch needs length: ISO 2553 writes the pitch in brackets after the weld length."
+ )
+
+ size_text = _weld_size_text(kind, size)
+ right_text = ""
+ if length is not None:
+ right_text = (
+ f"{float(length):g}" if pitch is None else f"{float(length):g} ({float(pitch):g})"
+ )
+
+ ident_y = -IDENT_OFFSET_FACTOR * h
+ cursor = (1.0 if (field_weld or all_around) else 0.5) * h
+ x_size = cursor
+ if size_text:
+ cursor += CHAR_WIDTH_FACTOR * h * len(size_text) + 0.4 * h
+ x_symbol = cursor
+ cursor += _WELD_WIDTH_FACTOR[kind] * h + 0.4 * h
+ x_right = cursor
+ if right_text:
+ cursor += CHAR_WIDTH_FACTOR * h * len(right_text)
+ ref_length = max(cursor + 0.5 * h, 6.0 * h)
+
+ prims: list[Prim] = [Line((0.0, 0.0), (ref_length, 0.0), "dim")]
+
+ if side != "both":
+ x = 0.0
+ dash, gap = IDENT_DASH_FACTOR * h, IDENT_GAP_FACTOR * h
+ while x < ref_length - 1e-9:
+ x_end = min(x + dash, ref_length)
+ prims.append(Line((x, ident_y), (x_end, ident_y), "dim"))
+ x = x_end + gap
+
+ # A symmetrical weld has no identification line, so its other-side symbol
+ # stands on the reference line; otherwise it stands on the identification
+ # line. `sense=-1` then grows it downward from that baseline.
+ other_baseline = 0.0 if side == "both" else ident_y
+ if side in ("arrow", "both"):
+ prims.extend(_elementary_prims(kind, x_symbol, 0.0, 1.0, h))
+ if side in ("other", "both"):
+ prims.extend(_elementary_prims(kind, x_symbol, other_baseline, -1.0, h))
+
+ # The dimensions sit beside their own symbol: 0.25h clear of the line the
+ # symbol stands on, on the symbol's side of it. For "both" that is the
+ # arrow-side symbol, which is where ISO 2553 writes a symmetrical weld's
+ # single set of dimensions.
+ text_y = ident_y - 1.25 * h if side == "other" else 0.25 * h
+ if size_text:
+ prims.append(Text((x_size, text_y), size_text, h))
+ if right_text:
+ prims.append(Text((x_right, text_y), right_text, h))
+
+ if field_weld:
+ top = FLAG_POLE_FACTOR * h
+ prims.append(Line((0.0, 0.0), (0.0, top), "dim"))
+ prims.append(
+ Poly(
+ ((0.0, top), (0.7 * h, top - 0.25 * h), (0.0, top - 0.5 * h)),
+ True,
+ "dim",
+ )
+ )
+ if all_around:
+ prims.append(Circle((0.0, 0.0), KINK_CIRCLE_FACTOR * h, "dim"))
+
+ if process:
+ fork = TAIL_FORK_FACTOR * h
+ prims.append(Line((ref_length, 0.0), (ref_length + fork, 0.5 * h), "dim"))
+ prims.append(Line((ref_length, 0.0), (ref_length + fork, -0.5 * h), "dim"))
+ prims.append(Text((ref_length + h, -0.35 * h), str(process), h))
+ return tuple(prims)
+
+
+# ── the async layer ─────────────────────────────────────────────────────────
+#
+# Task 7's `engineering/mech/draw.py` is a sibling branch (group M) and is not
+# importable from here, so the annotation tools carry their own small renderer.
+# It is deliberately named `draw_annotation_prims`, not `draw_prims`, so the
+# two coexist after the merge instead of colliding on a re-export.
+
+_DRAWABLE = (Line, Circle, Arc, Poly, Text)
+
+
+def leader_prims(
+ tip: tuple[float, float],
+ to: tuple[float, float],
+ *,
+ arrow_size: float = 2.5,
+ role: str = "dim",
+) -> tuple[Prim, ...]:
+ """A plain leader: a line from `tip` to `to` plus a solid arrowhead at `tip`.
+
+ `leader_create_mleader` is not used here: it refuses empty text
+ (`engineering/annotation.py::validate_mleader`), and a weld or surface
+ symbol needs the leader *without* an MTEXT label beside it. The arrowhead
+ reproduces the proportions that backend already draws (length `arrow_size`,
+ half-width 0.35 * `arrow_size`) so both routes look identical.
+ """
+ x0, y0 = float(tip[0]), float(tip[1])
+ x1, y1 = float(to[0]), float(to[1])
+ dx, dy = x1 - x0, y1 - y0
+ length = math.hypot(dx, dy)
+ if length <= 1e-12:
+ raise ValueError(
+ f"leader_to ({x0:g}, {y0:g}) and the symbol position ({x1:g}, {y1:g}) "
+ "must be distinct points."
+ )
+ ux, uy = dx / length, dy / length
+ px, py = -uy, ux
+ a = float(arrow_size)
+ return (
+ Line((x0, y0), (x1, y1), role),
+ Poly(
+ (
+ (x0, y0),
+ (x0 + ux * a + px * a * 0.35, y0 + uy * a + py * a * 0.35),
+ (x0 + ux * a - px * a * 0.35, y0 + uy * a - py * a * 0.35),
+ ),
+ True,
+ role,
+ ),
+ )
+
+
+#: The layer definitions `ROLE_LAYER` names, so a layer this module creates
+#: gets the colour/linetype/lineweight `drawing_new` would have given it
+#: instead of a bare white Continuous one.
+_LAYER_DEFS: dict[str, tuple[int, str, float]] = {
+ name: (color, linetype, lineweight)
+ for name, color, linetype, lineweight, _desc in ENGINEERING_LAYERS
+}
+
+
+async def ensure_annotation_layers(
+ backend: AutoCADBackend, names: Iterable[str]
+) -> tuple[str, ...]:
+ """Create whichever of `names` the drawing does not have yet; return those.
+
+ MEASURED on AutoCAD 2026: assigning `entity.Layer = "DIM"` on a drawing
+ whose layer table is `['0']` raises `-0x7ffdfff7 ('Key not found')` -
+ ActiveX will not create the layer for you. The headless engine will
+ (`EzdxfBackend._apply_attrs` calls `doc.layers.add`), so without this step
+ the same annotation silently succeeds headless and dies live on the first
+ entity, having already written the ones before it. `drawing_open` runs no
+ bootstrap, so annotating a foreign drawing - the case the spec names - is
+ exactly when the layer is missing. Ensuring every target layer up front,
+ before any entity is created, is what makes the two engines agree and what
+ keeps a refusal from leaving half a symbol behind.
+ """
+ wanted: list[str] = []
+ for name in names:
+ if name not in wanted:
+ wanted.append(name)
+ if not wanted:
+ return ()
+ existing = {lyr.name.lower() for lyr in await backend.layer_list()}
+ created: list[str] = []
+ for name in wanted:
+ if name.lower() in existing:
+ continue
+ color, linetype, lineweight = _LAYER_DEFS.get(name, (7, "Continuous", 0.25))
+ await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight)
+ created.append(name)
+ return tuple(created)
+
+
+async def draw_annotation_prims(
+ backend: AutoCADBackend,
+ prims,
+ *,
+ at: tuple[float, float] = (0.0, 0.0),
+ rotation: float = 0.0,
+ layer: str | None = None,
+) -> dict:
+ """Draw primitive descriptions through the generic entity contracts.
+
+ Rotates about the symbol origin first, then translates to `at`, so every
+ coordinate that reaches the backend is already WCS. Each primitive lands on
+ `ROLE_LAYER[role]` unless `layer` overrides it, and every target layer is
+ created first (`ensure_annotation_layers`) - live AutoCAD refuses an
+ assignment to a layer that is not in the table, and a foreign drawing that
+ was merely opened has no engineering layers at all.
+ """
+ placed = tuple(prims)
+ for prim in placed:
+ if not isinstance(prim, _DRAWABLE):
+ raise TypeError(
+ f"draw_annotation_prims cannot draw a {type(prim).__name__}; it draws "
+ "Line, Circle, Arc, Poly and Text."
+ )
+ if rotation:
+ placed = rotate(placed, float(rotation))
+ placed = translate(placed, float(at[0]), float(at[1]))
+
+ targets = [layer or ROLE_LAYER[prim.role] for prim in placed]
+ created = await ensure_annotation_layers(backend, targets)
+
+ handles: list[str] = []
+ for prim, target in zip(placed, targets, strict=True):
+ if isinstance(prim, Line):
+ info = await backend.entity_create_line(
+ prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], 0.0, 0.0, target
+ )
+ elif isinstance(prim, Circle):
+ info = await backend.entity_create_circle(
+ prim.center[0], prim.center[1], prim.radius, target
+ )
+ elif isinstance(prim, Arc):
+ info = await backend.entity_create_arc(
+ prim.center[0],
+ prim.center[1],
+ prim.radius,
+ prim.start_deg,
+ prim.end_deg,
+ target,
+ )
+ elif isinstance(prim, Poly):
+ info = await backend.entity_create_polyline(
+ [[float(p[0]), float(p[1])] for p in prim.points], prim.closed, target
+ )
+ else: # Text
+ info = await backend.entity_create_text(
+ prim.text, prim.at[0], prim.at[1], prim.height, prim.rotation, target
+ )
+ handles.append(info.handle)
+ return {
+ "ok": True,
+ "handles": handles,
+ "count": len(handles),
+ "layers_created": list(created),
+ }
+
+
+async def draw_surface_texture(
+ backend: AutoCADBackend,
+ *,
+ at: tuple[float, float],
+ leader_to: tuple[float, float] | None = None,
+ ra: float | None = None,
+ rz: float | None = None,
+ process: str | None = None,
+ lay: str | None = None,
+ machining: str = "any",
+ all_around: bool = False,
+ allowance: float | None = None,
+ height: float = 3.5,
+ standard: str = "ISO 21920-1",
+ rotation: float = 0.0,
+ layer: str | None = None,
+) -> dict:
+ """Place a surface-texture symbol with its apex at `at`."""
+ prims = surface_texture_prims(
+ ra=ra,
+ rz=rz,
+ process=process,
+ lay=lay,
+ machining=machining,
+ all_around=all_around,
+ height=height,
+ allowance=allowance,
+ standard=standard,
+ )
+ result = await draw_annotation_prims(backend, prims, at=at, rotation=rotation, layer=layer)
+ if leader_to is not None:
+ leader = leader_prims(
+ (float(leader_to[0]), float(leader_to[1])),
+ (float(at[0]), float(at[1])),
+ arrow_size=0.7 * float(height),
+ )
+ drawn = await draw_annotation_prims(backend, leader, layer=layer)
+ result["handles"].extend(drawn["handles"])
+ result["count"] = len(result["handles"])
+ result["layers_created"].extend(
+ name for name in drawn["layers_created"] if name not in result["layers_created"]
+ )
+ result["standard"] = standard
+ return result
+
+
+async def draw_weld_symbol(
+ backend: AutoCADBackend,
+ *,
+ at: tuple[float, float],
+ kind: str,
+ leader_to: tuple[float, float] | None = None,
+ size=None,
+ length: float | None = None,
+ pitch: float | None = None,
+ side: str = "arrow",
+ field_weld: bool = False,
+ all_around: bool = False,
+ process: str | None = None,
+ height: float = 3.5,
+ rotation: float = 0.0,
+ layer: str | None = None,
+) -> dict:
+ """Place an ISO 2553 weld annotation with its kink at `at`."""
+ prims = weld_symbol_prims(
+ kind=kind,
+ size=size,
+ length=length,
+ pitch=pitch,
+ side=side,
+ field_weld=field_weld,
+ all_around=all_around,
+ process=process,
+ height=height,
+ )
+ result = await draw_annotation_prims(backend, prims, at=at, rotation=rotation, layer=layer)
+ if leader_to is not None:
+ leader = leader_prims(
+ (float(leader_to[0]), float(leader_to[1])),
+ (float(at[0]), float(at[1])),
+ arrow_size=0.7 * float(height),
+ )
+ drawn = await draw_annotation_prims(backend, leader, layer=layer)
+ result["handles"].extend(drawn["handles"])
+ result["count"] = len(result["handles"])
+ result["layers_created"].extend(
+ name for name in drawn["layers_created"] if name not in result["layers_created"]
+ )
+ result["kind"] = kind
+ result["side"] = side
+ return result
diff --git a/engineering/mech/marks.py b/engineering/mech/marks.py
new file mode 100644
index 0000000..8179995
--- /dev/null
+++ b/engineering/mech/marks.py
@@ -0,0 +1,582 @@
+"""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 and the thin middle on the
+`center` role - the wide/narrow contrast, expressed in the role vocabulary
+this repository already has.
+
+The two widths are not numbers this module picks: they are whatever
+`engineering.layers.ENGINEERING_LAYERS` gives GEOMETRY and CENTER. MEASURED
+on a drawing `drawing_new` bootstrapped:
+
+ GEOMETRY is 0.50 mm, CENTER is 0.18 mm, a ratio of 2.78:1.
+
+That measured pair is what is stated here - not the standard's own
+wide:narrow ratio, which this module does not transcribe.
+A ratio quoted from memory beside a measured one is exactly the invented
+number the SOURCE blocks above refuse to ship, and an earlier revision of
+this block did ship one (it said CENTER was 0.25 mm and the pair 2:1; both
+were wrong). `tests/test_mech_marks.py` now pins these three figures to
+`ENGINEERING_LAYERS`, so the sentence cannot drift from the layer set again.
+
+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",
+ "HATCH_FLATTEN_SAGITTA",
+ "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
+
+# DECLARED, not transcribed: the largest gap (drawing units) left between a
+# curved boundary edge and the chords that stand in for it.
+# `entity_create_hatch` takes a vertex list on both engines, so an arc edge
+# has to reach it as vertices; what must not happen - and did - is dropping
+# the curve and hatching the chord. MEASURED on the r=20 semicircular cut
+# face in `tests/test_mech_marks.py`: 0.01 spaces that arc into 50 chords and
+# hatches 1027.905195 of its real 1028.318531, an error of 0.413 (0.040%),
+# against the 628.3 (61%) an earlier revision silently lost. The payload says
+# `accuracy: "flatten_tolerance"` rather than claiming the area is exact.
+HATCH_FLATTEN_SAGITTA = 0.01
+
+
+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
+
+
+def _polygon_area(points: list[tuple[float, float]]) -> float:
+ """The shoelace area of a closed straight-edged polygon.
+
+ Legitimate here and nowhere else in this module: it is applied to the
+ vertex list actually written into the HATCH, every edge of which really is
+ a straight line, so it measures what was drawn rather than approximating
+ what was meant. The curved input is handled by `_flatten_bulge` *before*
+ this sees it, and the residual is reported, never swallowed.
+ """
+ total = 0.0
+ count = len(points)
+ for index in range(count):
+ x1, y1 = points[index]
+ x2, y2 = points[(index + 1) % count]
+ total += x1 * y2 - x2 * y1
+ return abs(total) / 2.0
+
+
+def _flatten_bulge(
+ p1: tuple[float, float],
+ p2: tuple[float, float],
+ bulge: float,
+ tolerance: float = HATCH_FLATTEN_SAGITTA,
+) -> list[tuple[float, float]]:
+ """The interior vertices standing in for one bulged polyline segment.
+
+ DXF stores a circular arc between two LWPOLYLINE vertices as a *bulge*:
+ tan(theta/4) of the arc's included angle, positive counter-clockwise. The
+ points returned lie strictly between `p1` and `p2`, spaced so no chord
+ departs from the arc by more than `tolerance`; an empty list means the
+ segment is already straight.
+ """
+ b = float(bulge)
+ if not math.isfinite(b) or abs(b) <= 1e-12:
+ return []
+ x1, y1 = float(p1[0]), float(p1[1])
+ x2, y2 = float(p2[0]), float(p2[1])
+ chord = math.hypot(x2 - x1, y2 - y1)
+ if chord <= 1e-12:
+ return []
+ theta = 4.0 * math.atan(b)
+ half = theta / 2.0
+ radius = (chord / 2.0) / abs(math.sin(half))
+ # Centre: off the chord's midpoint along its left normal, signed by the
+ # sweep, so a negative bulge puts it on the other side without a branch.
+ ux, uy = (x2 - x1) / chord, (y2 - y1) / chord
+ offset = -(chord / 2.0) / math.tan(half)
+ cx = (x1 + x2) / 2.0 + (-uy) * offset
+ cy = (y1 + y2) / 2.0 + ux * offset
+ tol = min(float(tolerance), radius)
+ step = 2.0 * math.acos(max(-1.0, min(1.0, 1.0 - tol / radius)))
+ count = max(2, math.ceil(abs(theta) / step)) if step > 1e-12 else 2
+ start = math.atan2(y1 - cy, x1 - cx)
+ return [
+ (
+ cx + radius * math.cos(start + theta * index / count),
+ cy + radius * math.sin(start + theta * index / count),
+ )
+ for index in range(1, count)
+ ]
+
+
+async def _discard_traced_loop(backend: AutoCADBackend, handle: str | None) -> bool:
+ """Delete the outline `boundary_from_entities` drew, reporting whether it went.
+
+ `boundary_from_entities` does not merely compute a loop, it *writes* one:
+ an LWPOLYLINE on the current layer. `hatch_material` asked for a hatch, not
+ an outline, so leaving it behind drops a stray polyline on layer 0 that the
+ caller cannot find - and that `drawing_critique` / `drawing_finalize` then
+ score against the drawing under the layer-discipline rule. The boolean is
+ put in the result rather than assumed, so a delete that failed is visible.
+ """
+ if not handle:
+ return False
+ try:
+ result = await backend.entity_delete(handle)
+ except Exception:
+ return False
+ if isinstance(result, dict):
+ return bool(result.get("ok", True))
+ return True
+
+
+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.
+
+ With `handles` the loop is chained by `boundary_from_entities`, and both of
+ that call's side effects are dealt with here rather than left to the
+ caller. Its traced LWPOLYLINE carries `points` **and** a parallel `bulges`
+ list; reading only `points` hatches the chord of every arc edge, which on a
+ semicircular cut face leaves 61% of the face unhatched while reporting
+ success. Each bulged segment is therefore expanded into chords no further
+ than `HATCH_FLATTEN_SAGITTA` from the real arc, and the payload states its
+ own accuracy the way `analysis_measure_entity` does: `accuracy` is
+ `"exact"` or `"flatten_tolerance"`, `area` is the polygon actually written
+ and `boundary_area` the exact area of the chained loop, so the two can be
+ compared instead of trusted. The traced outline itself is deleted - it was
+ scaffolding, and an orphan polyline on layer 0 is scored against the
+ drawing - and `traced` reports its handle and whether it went.
+
+ Refused: a loop that encloses no area, and a traced loop that flattens to
+ fewer than three vertices. Both used to write a HATCH of area 0.0 and
+ return `ok`.
+ """
+ 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.")
+
+ traced_report: dict | None = None
+ boundary_area: float | None = None
+ curved_edges = 0
+ 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_handle = loop.get("handle")
+ if loop.get("area") is not None:
+ boundary_area = float(loop["area"])
+ try:
+ traced = await backend.entity_get(traced_handle)
+ vertices = [(float(p[0]), float(p[1])) for p in traced.properties["points"]]
+ bulges = [float(b) for b in (traced.properties.get("bulges") or ())]
+ finally:
+ # In `finally:` so a drawing is never left with the scaffolding of
+ # a call that raised while reading it.
+ traced_report = {
+ "handle": traced_handle,
+ "removed": await _discard_traced_loop(backend, traced_handle),
+ }
+ points = []
+ for index, vertex in enumerate(vertices):
+ points.append(vertex)
+ bulge = bulges[index] if index < len(bulges) else 0.0
+ arc = _flatten_bulge(vertex, vertices[(index + 1) % len(vertices)], bulge)
+ if arc:
+ curved_edges += 1
+ points.extend(arc)
+ if len(points) < 3:
+ raise ValueError(
+ f"the chained loop flattens to {len(points)} vertices, which encloses nothing; "
+ "a hatch boundary needs at least three."
+ )
+
+ area = _polygon_area(points)
+ if area <= 1e-9:
+ raise ValueError(
+ f"the hatch boundary encloses no area (measured {area:g}); a zero-area HATCH "
+ "fills nothing while reporting success."
+ )
+ # The traced loop knows its own exact area, arcs and all. If the polygon
+ # written here does not match it, something was straightened - say so by
+ # name instead of reporting an area that is not the cut face's.
+ if boundary_area is None or abs(area - boundary_area) <= max(1e-9, 1e-9 * abs(boundary_area)):
+ accuracy, flatten_tolerance = "exact", None
+ else:
+ accuracy, flatten_tolerance = "flatten_tolerance", HATCH_FLATTEN_SAGITTA
+
+ 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"],
+ [[x, y] for x, y in 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),
+ "area": round(area, 6),
+ "boundary_area": boundary_area,
+ "accuracy": accuracy,
+ "flatten_tolerance": flatten_tolerance,
+ "curved_edges": curved_edges,
+ "layer": target,
+ "layers_created": list(created),
+ "traced": traced_report,
+ }
diff --git a/server.py b/server.py
index e14ad9f..6a48a63 100644
--- a/server.py
+++ b/server.py
@@ -1017,6 +1017,11 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"std_part_list",
"std_part_insert",
"std_feature_draw",
+ "surface_texture",
+ "weld_symbol",
+ "centre_marks",
+ "section_line",
+ "hatch_material",
}
),
}
@@ -8764,6 +8769,357 @@ async def std_feature_draw(
)
+# ---------------------------------------------------------------------------
+# ── 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
+# engineering/mech/annotate.py with their sources; a value outside a
+# transcribed table is refused there, before the first entity is written.
+
+
+@cad_tool(
+ summary="Draw an ISO 21920-1 surface-texture symbol: Ra/Rz, process, lay, allowance.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Surface Texture Symbol (ISO 21920-1)", "destructiveHint": False},
+ tags={"engineering", "mech"},
+)
+async def surface_texture(
+ x: Annotated[float, "Symbol apex X (WCS) — the point the leader attaches to."],
+ y: Annotated[float, "Symbol apex Y (WCS)."],
+ ra: Annotated[
+ float | None, Field(default=None, description="Ra value in µm, written as 'Ra 3.2'.")
+ ] = None,
+ rz: Annotated[
+ float | None, Field(default=None, description="Rz value in µm, written as 'Rz 12.5'.")
+ ] = None,
+ machining: Annotated[
+ str,
+ Field(
+ default="any",
+ description=(
+ "any (basic vee) | required (bar across the vee, material removal "
+ "required) | prohibited (circle in the vee, material removal not allowed)."
+ ),
+ ),
+ ] = "any",
+ process: Annotated[
+ str | None, "Manufacturing method, treatment or coating, e.g. 'milled'."
+ ] = None,
+ lay: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description=(
+ "Direction of lay: parallel, perpendicular, crossed, multidirectional, "
+ "circular, radial, particulate."
+ ),
+ ),
+ ] = None,
+ allowance: Annotated[
+ float | None, "Machining allowance in mm, written below the extension line."
+ ] = None,
+ all_around: Annotated[bool, "Circle at the kink: the requirement applies all around."] = False,
+ leader_to: Annotated[
+ list[float] | None, "[x, y] on the surface; draws a leader with an arrowhead there."
+ ] = None,
+ height: Annotated[
+ float,
+ Field(
+ default=3.5,
+ gt=0,
+ description="Text height (mm). ISO 1302 tabulates 2.5, 3.5, 5, 7, 10, 14, 20.",
+ ),
+ ] = 3.5,
+ rotation: Annotated[float, "Rotate the whole symbol about its apex (degrees CCW)."] = 0.0,
+ standard: Annotated[
+ str, "ISO 21920-1 (default) or ISO 1302 for drawings issued under the older designation."
+ ] = "ISO 21920-1",
+ layer: Annotated[
+ str | None, "Override the role layers (DIM for geometry, TEXT for text)."
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """Draw the 60° surface-texture vee with its annotation in the standard positions.
+
+ Refusals, all before the first entity reaches the drawing: a text height
+ outside the seven ISO 1302 rows (2.5/3.5/5/7/10/14/20 mm — never
+ interpolated), a `lay` that is not one of the seven ISO 1302 directions of
+ lay, a `machining` outside any/required/prohibited, and a `standard` other
+ than ISO 21920-1 or ISO 1302.
+ """
+ from engineering.mech.annotate import draw_surface_texture
+
+ await ctx.debug(f"Surface texture symbol at ({x}, {y})")
+ return await draw_surface_texture(
+ _backend(ctx),
+ at=(x, y),
+ leader_to=tuple(leader_to) if leader_to else None,
+ ra=ra,
+ rz=rz,
+ process=process,
+ lay=lay,
+ machining=machining,
+ all_around=all_around,
+ allowance=allowance,
+ height=height,
+ standard=standard,
+ rotation=rotation,
+ layer=layer,
+ )
+
+
+@cad_tool(
+ summary="Draw an ISO 2553 weld symbol: reference line, identification line, size and pitch.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Weld Symbol (ISO 2553)", "destructiveHint": False},
+ tags={"engineering", "mech"},
+)
+async def weld_symbol(
+ x: Annotated[float, "Kink X (WCS) — where the arrow line meets the reference line."],
+ y: Annotated[float, "Kink Y (WCS)."],
+ kind: Annotated[str, "Elementary symbol: square | v | bevel | u | j | fillet."] = "fillet",
+ size: Annotated[
+ float | str | None,
+ Field(
+ default=None,
+ description=(
+ "Written before the symbol. A number on a fillet takes the ISO 2553 "
+ "design-throat prefix (5 → 'a5'); pass a string for leg length ('z7')."
+ ),
+ ),
+ ] = None,
+ length: Annotated[float | None, "Weld length (mm), written after the symbol."] = None,
+ pitch: Annotated[
+ float | None, "Pitch (mm) of an intermittent weld, written in brackets after the length."
+ ] = None,
+ side: Annotated[
+ str,
+ Field(
+ default="arrow",
+ description=(
+ "arrow (symbol on the reference line) | other (on the dashed "
+ "identification line) | both (symmetrical; the identification line is "
+ "omitted, per ISO 2553:2019)."
+ ),
+ ),
+ ] = "arrow",
+ field_weld: Annotated[bool, "Flag at the kink: weld made on site."] = False,
+ all_around: Annotated[bool, "Circle at the kink: weld all around."] = False,
+ process: Annotated[str | None, "Tail reference, e.g. an ISO 4063 process number."] = None,
+ leader_to: Annotated[
+ list[float] | None, "[x, y] on the joint; draws a leader with an arrowhead there."
+ ] = None,
+ height: Annotated[float, Field(default=3.5, gt=0, description="Text height (mm).")] = 3.5,
+ rotation: Annotated[float, "Rotate the whole annotation about the kink (degrees CCW)."] = 0.0,
+ layer: Annotated[
+ str | None, "Override the role layers (DIM for geometry, TEXT for text)."
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """Draw an ISO 2553 weld annotation: reference line, identification line, symbol, dimensions.
+
+ Refusals, all before the first entity reaches the drawing: a `kind` outside
+ the six elementary symbols transcribed here (square, v, bevel, u, j,
+ fillet — the rest of the ISO 2553 table is deliberately not shipped rather
+ than guessed), a `side` outside arrow/other/both, a non-positive `height`,
+ and a `pitch` given without a `length`.
+ """
+ from engineering.mech.annotate import draw_weld_symbol
+
+ await ctx.debug(f"Weld symbol {kind} at ({x}, {y})")
+ return await draw_weld_symbol(
+ _backend(ctx),
+ at=(x, y),
+ kind=kind,
+ leader_to=tuple(leader_to) if leader_to else None,
+ size=size,
+ length=length,
+ pitch=pitch,
+ side=side,
+ field_weld=field_weld,
+ all_around=all_around,
+ process=process,
+ height=height,
+ rotation=rotation,
+ layer=layer,
+ )
+
+
+@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.
+
+ The payload states its own accuracy, as `analysis_measure_entity` does:
+ `area` is the polygon actually hatched, `boundary_area` the exact area of
+ the chained loop, and `accuracy` is `"exact"` or `"flatten_tolerance"` -
+ an arc edge reaches `entity_create_hatch` as chords, so a curved cut face
+ is hatched to within `HATCH_FLATTEN_SAGITTA` of itself rather than being
+ replaced by its chord polygon. With `handles`, the outline
+ `boundary_from_entities` draws is scaffolding and is deleted; `traced`
+ reports its handle and whether the delete succeeded. A loop enclosing no
+ area is refused instead of writing a HATCH of area 0.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_annotate.py b/tests/test_mech_annotate.py
new file mode 100644
index 0000000..b1ea98a
--- /dev/null
+++ b/tests/test_mech_annotate.py
@@ -0,0 +1,543 @@
+"""ISO 21920-1 / ISO 1302 surface texture and ISO 2553 weld symbols.
+
+Pure geometry is asserted as emitted primitive tuples (counts, roles,
+coordinates to 1e-9); the async layer is asserted against a real ezdxf
+document through the `backend` fixture. No screenshots anywhere.
+"""
+
+from __future__ import annotations
+
+import math
+import types
+
+import pytest
+
+from engineering.mech.annotate import (
+ ISO1302_SYMBOL_PROPORTIONS,
+ LAY_SYMBOLS,
+ MACHINING_KINDS,
+ SURFACE_HEIGHTS,
+ SURFACE_STANDARDS,
+ surface_texture_prims,
+ symbol_proportions,
+)
+from engineering.mech.primitives import Circle, Line, Text
+
+pytestmark = pytest.mark.asyncio
+
+EPS = 1e-9
+K = 1.0 / math.sqrt(3.0) # tan(30 deg): the 60 deg legs' horizontal run per unit rise
+
+
+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_the_seven_iso1302_proportion_rows_are_the_transcribed_ones():
+ assert SURFACE_HEIGHTS == (2.5, 3.5, 5.0, 7.0, 10.0, 14.0, 20.0)
+ assert ISO1302_SYMBOL_PROPORTIONS[2.5] == (0.25, 3.5, 7.5)
+ assert ISO1302_SYMBOL_PROPORTIONS[3.5] == (0.35, 5.0, 10.5)
+ assert ISO1302_SYMBOL_PROPORTIONS[5.0] == (0.5, 7.0, 15.0)
+ assert ISO1302_SYMBOL_PROPORTIONS[7.0] == (0.7, 10.0, 21.0)
+ assert ISO1302_SYMBOL_PROPORTIONS[10.0] == (1.0, 14.0, 30.0)
+ assert ISO1302_SYMBOL_PROPORTIONS[14.0] == (1.4, 20.0, 42.0)
+ assert ISO1302_SYMBOL_PROPORTIONS[20.0] == (2.0, 28.0, 60.0)
+ # Every row: line width is h/10 and the minimum overall height is 3h.
+ for height, (width, _h1, h2) in ISO1302_SYMBOL_PROPORTIONS.items():
+ assert width == pytest.approx(height / 10.0, abs=EPS)
+ assert h2 == pytest.approx(3.0 * height, abs=EPS)
+
+
+async def test_a_text_height_outside_the_table_is_refused_by_name():
+ with pytest.raises(ValueError) as excinfo:
+ symbol_proportions(4.0)
+ message = str(excinfo.value)
+ assert "ISO 1302" in message and "4 mm" in message
+ assert "2.5, 3.5, 5, 7, 10, 14, 20" in message
+
+
+async def test_the_basic_symbol_is_a_60_degree_vee_on_the_dim_role():
+ prims = surface_texture_prims(height=3.5)
+ short, long_ = prims[0], prims[1]
+ assert isinstance(short, Line) and isinstance(long_, Line)
+ assert short.p1 == (0.0, 0.0) and long_.p1 == (0.0, 0.0)
+ assert short.p2 == pytest.approx((-5.0 * K, 5.0), abs=EPS)
+ assert long_.p2 == pytest.approx((10.5 * K, 10.5), abs=EPS)
+ assert short.role == "dim" and long_.role == "dim"
+ # The angle between the two legs is 60 degrees.
+ a = math.atan2(short.p2[1], short.p2[0])
+ b = math.atan2(long_.p2[1], long_.p2[0])
+ assert math.degrees(a - b) == pytest.approx(60.0, abs=1e-9)
+ # No bar, no circle: the basic symbol plus the extension line only.
+ assert [type(p).__name__ for p in prims] == ["Line", "Line", "Line"]
+
+
+async def test_machining_required_closes_the_vee_with_the_bar():
+ prims = surface_texture_prims(height=3.5, machining="required")
+ bar = prims[2]
+ assert isinstance(bar, Line)
+ assert bar.p1 == pytest.approx((-5.0 * K, 5.0), abs=EPS)
+ assert bar.p2 == pytest.approx((5.0 * K, 5.0), abs=EPS)
+ assert bar.role == "dim"
+
+
+async def test_machining_prohibited_inscribes_the_circle_in_the_vee():
+ prims = surface_texture_prims(height=3.5, machining="prohibited")
+ circle = prims[2]
+ assert isinstance(circle, Circle)
+ assert circle.center == pytest.approx((0.0, 10.0 / 3.0), abs=EPS)
+ assert circle.radius == pytest.approx(5.0 / 3.0, abs=EPS)
+ # Tangent to the bar height H1 and to both legs.
+ assert circle.center[1] + circle.radius == pytest.approx(5.0, abs=EPS)
+
+
+async def test_ra_rz_process_lay_and_allowance_land_in_their_declared_positions():
+ prims = surface_texture_prims(
+ height=3.5, ra=3.2, rz=12.5, process="milled", lay="perpendicular", allowance=0.5
+ )
+ texts = [p for p in prims if isinstance(p, Text)]
+ assert [t.text for t in texts] == ["Ra 3.2", "Rz 12.5", "milled", "⊥ 0.5"]
+ x_leg = 10.5 * K
+ for t in texts:
+ assert t.at[0] == pytest.approx(x_leg + 0.5 * 3.5, abs=EPS)
+ assert t.height == 3.5 and t.role == "text"
+ # Above the extension line the three lines stack upward at 1.5h pitch.
+ assert texts[0].at[1] == pytest.approx(10.5 + 0.35 * 3.5, abs=EPS)
+ assert texts[1].at[1] == pytest.approx(10.5 + 0.35 * 3.5 + 1.5 * 3.5, abs=EPS)
+ assert texts[2].at[1] == pytest.approx(10.5 + 0.35 * 3.5 + 3.0 * 3.5, abs=EPS)
+ # Lay and allowance sit below it, on one line.
+ assert texts[3].at[1] == pytest.approx(10.5 - 0.35 * 3.5 - 3.5, abs=EPS)
+
+
+async def test_the_extension_line_grows_with_the_widest_text():
+ prims = surface_texture_prims(height=3.5, ra=3.2)
+ extension = [p for p in prims if isinstance(p, Line)][-1]
+ x_leg = 10.5 * K
+ expected = 0.62 * 3.5 * len("Ra 3.2") + 3.5
+ assert extension.p1 == pytest.approx((x_leg, 10.5), abs=EPS)
+ assert extension.p2 == pytest.approx((x_leg + expected, 10.5), abs=EPS)
+ # With no text at all it falls back to the declared minimum of 2h.
+ bare = [p for p in surface_texture_prims(height=3.5) if isinstance(p, Line)][-1]
+ assert bare.p2[0] - bare.p1[0] == pytest.approx(2.0 * 3.5, abs=EPS)
+
+
+async def test_all_around_puts_the_circle_on_the_kink():
+ prims = surface_texture_prims(height=3.5, all_around=True)
+ circle = [p for p in prims if isinstance(p, Circle)][0]
+ assert circle.center == pytest.approx((10.5 * K, 10.5), abs=EPS)
+ assert circle.radius == pytest.approx(0.35 * 3.5, abs=EPS)
+
+
+async def test_iso1302_is_accepted_as_an_alias_designation_and_draws_the_same_symbol():
+ assert SURFACE_STANDARDS == ("ISO 21920-1", "ISO 1302")
+ new = surface_texture_prims(height=3.5, ra=3.2, standard="ISO 21920-1")
+ old = surface_texture_prims(height=3.5, ra=3.2, standard="ISO 1302")
+ assert new == old
+ with pytest.raises(ValueError) as excinfo:
+ surface_texture_prims(height=3.5, standard="ASME B46.1")
+ assert "ISO 21920-1, ISO 1302" in str(excinfo.value)
+
+
+async def test_an_undefined_lay_and_an_undefined_machining_are_refused_with_the_list():
+ assert set(LAY_SYMBOLS) == {
+ "parallel",
+ "perpendicular",
+ "crossed",
+ "multidirectional",
+ "circular",
+ "radial",
+ "particulate",
+ }
+ assert MACHINING_KINDS == ("any", "required", "prohibited")
+ with pytest.raises(ValueError) as excinfo:
+ surface_texture_prims(lay="diagonal")
+ assert "circular, crossed, multidirectional" in str(excinfo.value)
+ with pytest.raises(ValueError) as excinfo:
+ surface_texture_prims(machining="maybe")
+ assert "any, required, prohibited" in str(excinfo.value)
+
+
+# ── ISO 2553 weld symbols ───────────────────────────────────────────────────
+
+from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tests
+ WELD_KINDS,
+ WELD_SIDES,
+ weld_symbol_prims,
+)
+from engineering.mech.primitives import Arc, Poly # noqa: E402
+
+
+async def test_only_the_six_transcribed_elementary_symbols_are_offered():
+ assert WELD_KINDS == ("square", "v", "bevel", "u", "j", "fillet")
+ assert WELD_SIDES == ("arrow", "other", "both")
+ with pytest.raises(ValueError) as excinfo:
+ weld_symbol_prims(kind="spot")
+ message = str(excinfo.value)
+ assert "square, v, bevel, u, j, fillet" in message
+ assert "ISO 2553" in message
+ with pytest.raises(ValueError) as excinfo:
+ weld_symbol_prims(kind="fillet", side="both_sides")
+ assert "arrow, other, both" in str(excinfo.value)
+
+
+async def test_the_reference_line_comes_first_and_the_identification_line_is_dashed():
+ prims = weld_symbol_prims(kind="fillet", height=3.5)
+ reference = prims[0]
+ assert isinstance(reference, Line)
+ assert reference.p1 == (0.0, 0.0)
+ assert reference.p2[1] == 0.0 and reference.p2[0] > 0.0
+ assert reference.role == "dim"
+ length = reference.p2[0]
+ dashes = [
+ p for p in prims if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS)
+ ]
+ assert len(dashes) >= 2
+ assert all(d.p2[1] == pytest.approx(-0.4 * 3.5, abs=EPS) for d in dashes)
+ assert dashes[0].p1[0] == pytest.approx(0.0, abs=EPS)
+ assert dashes[0].p2[0] - dashes[0].p1[0] == pytest.approx(0.8 * 3.5, abs=EPS)
+ assert dashes[1].p1[0] - dashes[0].p2[0] == pytest.approx(0.4 * 3.5, abs=EPS)
+ assert dashes[-1].p2[0] <= length + EPS
+
+
+async def test_a_symmetrical_weld_meets_back_to_back_on_the_reference_line():
+ """ISO 2553: no identification line, and the two elementary symbols share it.
+
+ The earlier version of this test asserted the lower V apex at -0.4h, i.e.
+ on the (omitted) identification line, which drew a double-V butt weld as
+ two detached halves that were not even symmetric about the reference line.
+ """
+ prims = weld_symbol_prims(kind="v", side="both", height=3.5)
+ assert not [
+ p for p in prims if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS)
+ ]
+ # One V above the reference line, one below it, both standing on y = 0.
+ vees = [p for p in prims if isinstance(p, Poly) and len(p.points) == 3 and not p.closed]
+ assert len(vees) == 2
+ upper, lower = vees
+ assert upper.points[1][1] == pytest.approx(0.0, abs=EPS)
+ assert lower.points[1][1] == pytest.approx(0.0, abs=EPS)
+ # Mirror images of each other about y = 0, same x.
+ assert _flat(lower.points) == pytest.approx(
+ tuple(v if i % 2 == 0 else -v for i, v in enumerate(_flat(upper.points))), abs=EPS
+ )
+ ys = [y for _x, y in upper.points + lower.points]
+ assert (min(ys), max(ys)) == pytest.approx((-3.5, 3.5), abs=EPS)
+
+
+async def test_a_symmetrical_fillet_weld_is_the_double_fillet_not_two_halves():
+ prims = weld_symbol_prims(kind="fillet", side="both", height=3.5)
+ triangles = [p for p in prims if isinstance(p, Poly) and p.closed]
+ assert len(triangles) == 2
+ x0 = triangles[0].points[0][0]
+ assert _flat(triangles[0].points) == pytest.approx((x0, 0.0, x0, 3.5, x0 + 3.5, 0.0), abs=EPS)
+ assert _flat(triangles[1].points) == pytest.approx((x0, 0.0, x0, -3.5, x0 + 3.5, 0.0), abs=EPS)
+
+
+async def test_the_fillet_triangle_stands_on_the_line_with_its_leg_on_the_left():
+ prims = weld_symbol_prims(kind="fillet", height=3.5)
+ triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
+ x0 = triangle.points[0][0]
+ assert _flat(triangle.points) == pytest.approx((x0, 0.0, x0, 3.5, x0 + 3.5, 0.0), abs=EPS)
+ assert triangle.role == "dim"
+
+
+async def test_the_other_side_symbol_hangs_below_the_identification_line():
+ prims = weld_symbol_prims(kind="fillet", side="other", height=3.5)
+ triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
+ x0 = triangle.points[0][0]
+ assert _flat(triangle.points) == pytest.approx(
+ (x0, -1.4, x0, -1.4 - 3.5, x0 + 3.5, -1.4), abs=EPS
+ )
+
+
+async def test_other_side_dimensions_are_written_beside_the_other_side_symbol():
+ """ISO 2553 writes the size left of the symbol and the length right of it.
+
+ For an other-side weld the symbol hangs from the identification line, so
+ both texts belong there too. Written above the reference line instead,
+ `a6` sits exactly where an arrow-side throat dimension sits - on a joint
+ that has no arrow-side weld.
+ """
+ prims = weld_symbol_prims(kind="fillet", side="other", size=6, length=50, height=3.5)
+ triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
+ x_symbol = triangle.points[0][0]
+ texts = [p for p in prims if isinstance(p, Text)]
+ assert [t.text for t in texts] == ["a6", "50"]
+ # Beside the symbol: size to its left, length to its right.
+ assert texts[0].at[0] < x_symbol
+ assert texts[1].at[0] > x_symbol + 3.5
+ # Below the reference line, 0.25h clear of the identification line, so the
+ # whole text body lies between the identification line and the symbol's
+ # far edge - never above y = 0.
+ ident_y = -0.4 * 3.5
+ for t in texts:
+ assert t.at[1] == pytest.approx(ident_y - 1.25 * 3.5, abs=EPS)
+ assert t.at[1] + t.height < 0.0
+ # The arrow side is unchanged: its dimensions stay above the reference line.
+ arrow = [
+ p
+ for p in weld_symbol_prims(kind="fillet", side="arrow", size=6, length=50, height=3.5)
+ if isinstance(p, Text)
+ ]
+ assert all(t.at[1] == pytest.approx(0.25 * 3.5, abs=EPS) for t in arrow)
+ # A symmetrical weld carries one set, beside its arrow-side symbol.
+ both = [
+ p
+ for p in weld_symbol_prims(kind="fillet", side="both", size=6, length=50, height=3.5)
+ if isinstance(p, Text)
+ ]
+ assert all(t.at[1] == pytest.approx(0.25 * 3.5, abs=EPS) for t in both)
+
+
+async def test_the_u_symbol_is_a_half_circle_between_two_uprights():
+ prims = weld_symbol_prims(kind="u", height=3.5)
+ arc = [p for p in prims if isinstance(p, Arc)][0]
+ assert arc.radius == pytest.approx(1.75, abs=EPS)
+ assert (arc.start_deg, arc.end_deg) == (180.0, 360.0)
+ assert arc.center[1] == pytest.approx(1.75, abs=EPS)
+ mirrored = [
+ p for p in weld_symbol_prims(kind="u", side="other", height=3.5) if isinstance(p, Arc)
+ ][0]
+ assert (mirrored.start_deg, mirrored.end_deg) == (0.0, 180.0)
+ assert mirrored.center[1] == pytest.approx(-1.4 - 1.75, abs=EPS)
+
+
+async def test_size_is_written_before_the_symbol_and_length_and_pitch_after_it():
+ prims = weld_symbol_prims(kind="fillet", size=5, length=50, pitch=100, height=3.5)
+ texts = [p for p in prims if isinstance(p, Text)]
+ assert [t.text for t in texts] == ["a5", "50 (100)"]
+ triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
+ x_symbol = triangle.points[0][0]
+ assert texts[0].at[0] < x_symbol
+ assert texts[1].at[0] > x_symbol + 3.5
+ # A string size is written verbatim, so z-notation stays available.
+ verbatim = weld_symbol_prims(kind="fillet", size="z7", height=3.5)
+ assert [p.text for p in verbatim if isinstance(p, Text)] == ["z7"]
+ with pytest.raises(ValueError) as excinfo:
+ weld_symbol_prims(kind="fillet", pitch=100, height=3.5)
+ assert "pitch needs length" in str(excinfo.value)
+
+
+async def test_field_weld_flag_and_all_around_circle_sit_on_the_kink():
+ prims = weld_symbol_prims(kind="fillet", field_weld=True, all_around=True, height=3.5)
+ pole = [p for p in prims if isinstance(p, Line) and p.p1 == (0.0, 0.0) and p.p2[0] == 0.0][0]
+ assert pole.p2 == pytest.approx((0.0, 1.4 * 3.5), abs=EPS)
+ flag = [p for p in prims if isinstance(p, Poly) and p.closed and p.points[0][0] == 0.0][0]
+ assert _flat(flag.points) == pytest.approx(
+ (
+ 0.0,
+ 1.4 * 3.5,
+ 0.7 * 3.5,
+ 1.4 * 3.5 - 0.25 * 3.5,
+ 0.0,
+ 1.4 * 3.5 - 0.5 * 3.5,
+ ),
+ abs=EPS,
+ )
+ circle = [p for p in prims if isinstance(p, Circle)][0]
+ assert circle.center == (0.0, 0.0)
+ assert circle.radius == pytest.approx(0.4 * 3.5, abs=EPS)
+
+
+async def test_a_process_reference_gets_the_tail_fork_at_the_far_end():
+ prims = weld_symbol_prims(kind="fillet", process="ISO 4063 - 135", height=3.5)
+ reference = prims[0]
+ length = reference.p2[0]
+ fork = [
+ p for p in prims if isinstance(p, Line) and p.p1 == pytest.approx((length, 0.0), abs=EPS)
+ ]
+ assert len(fork) == 2
+ assert fork[0].p2 == pytest.approx((length + 0.8 * 3.5, 0.5 * 3.5), abs=EPS)
+ assert fork[1].p2 == pytest.approx((length + 0.8 * 3.5, -0.5 * 3.5), abs=EPS)
+ tail_text = [p for p in prims if isinstance(p, Text)][-1]
+ assert tail_text.text == "ISO 4063 - 135"
+ assert tail_text.at[0] == pytest.approx(length + 3.5, abs=EPS)
+
+
+# ── the async layer: primitives -> a real ezdxf document ────────────────────
+
+from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tests
+ draw_annotation_prims,
+ draw_surface_texture,
+ draw_weld_symbol,
+ leader_prims,
+)
+from engineering.mech.primitives import ROLE_LAYER # noqa: E402
+
+
+class _StrictLayerBackend:
+ """A backend that refuses an entity on a layer the drawing does not have.
+
+ MEASURED on AutoCAD 2026: `Documents.Add()` gives a layer table of `['0']`,
+ and `entity.Layer = "DIM"` on it raises
+ `AutoCAD COM error (-0x7ffdfff7) ('Key not found')` - live ActiveX never
+ auto-creates the layer, while `EzdxfBackend._apply_attrs` does. This fake
+ models that measured refusal (and only it: it has no member the real
+ `AutoCADBackend` lacks), so the headless suite fails the way the live
+ engine fails. Before the fix it recorded one LINE and then raised; the
+ drawing kept that orphan, which is the partial write the fix removes.
+ """
+
+ def __init__(self, layers=("0",)):
+ self.layers = {name.lower(): name for name in layers}
+ self.calls: list[tuple] = []
+ self._next = 0
+
+ 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)
+
+ def _entity(self, kind, layer):
+ 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((kind, layer))
+ self._next += 1
+ return types.SimpleNamespace(handle=f"{self._next:X}")
+
+ async def entity_create_line(self, x1, y1, x2, y2, z1=0.0, z2=0.0, layer=None, **kw):
+ return self._entity("LINE", layer)
+
+ async def entity_create_circle(self, cx, cy, radius, layer=None, **kw):
+ return self._entity("CIRCLE", layer)
+
+ async def entity_create_arc(self, cx, cy, radius, start_angle, end_angle, layer=None, **kw):
+ return self._entity("ARC", layer)
+
+ async def entity_create_polyline(self, points, closed=False, layer=None, **kw):
+ return self._entity("LWPOLYLINE", layer)
+
+ async def entity_create_text(self, text, x, y, height=2.5, rotation=0.0, layer=None, **kw):
+ return self._entity("TEXT", layer)
+
+
+async def test_every_target_layer_is_created_before_the_first_entity():
+ backend = _StrictLayerBackend()
+ result = await draw_surface_texture(
+ backend, at=(0.0, 0.0), ra=3.2, machining="required", lay="perpendicular", allowance=0.5
+ )
+ assert result["ok"] is True and result["count"] == 6
+ assert result["layers_created"] == [ROLE_LAYER["dim"], ROLE_LAYER["text"]]
+ # Nothing is drawn until every layer the symbol needs exists.
+ first_entity = next(
+ i
+ for i, call in enumerate(backend.calls)
+ if call[0] != "layer_list" and call[0] != "layer_create"
+ )
+ assert {call[1] for call in backend.calls[:first_entity] if call[0] == "layer_create"} == {
+ ROLE_LAYER["dim"],
+ ROLE_LAYER["text"],
+ }
+ # Created with their ENGINEERING_LAYERS definition, not a bare default.
+ dim_create = next(c for c in backend.calls if c[0] == "layer_create" and c[1] == "DIM")
+ assert dim_create[2:] == (2, "Continuous", 0.18)
+
+
+async def test_a_drawing_that_already_has_the_layers_is_not_rewritten():
+ backend = _StrictLayerBackend(layers=("0", "dim", "TEXT"))
+ result = await draw_weld_symbol(
+ backend, at=(0.0, 0.0), kind="fillet", size=5, length=50, leader_to=(-10.0, -10.0)
+ )
+ assert result["layers_created"] == []
+ assert not [c for c in backend.calls if c[0] == "layer_create"]
+
+
+async def _types_and_layers(backend, handles):
+ rows = []
+ for handle in handles:
+ info = await backend.entity_get(handle)
+ rows.append((info.type, info.layer))
+ return rows
+
+
+async def test_draw_annotation_prims_puts_every_role_on_its_own_layer(backend):
+ prims = (
+ Line((0.0, 0.0), (10.0, 0.0), "dim"),
+ Circle((5.0, 5.0), 2.0, "dim"),
+ Text((0.0, 12.0), "Ra 3.2", 3.5),
+ )
+ result = await draw_annotation_prims(backend, prims, at=(100.0, 50.0))
+ assert result["ok"] is True and result["count"] == 3
+ assert await _types_and_layers(backend, result["handles"]) == [
+ ("LINE", ROLE_LAYER["dim"]),
+ ("CIRCLE", ROLE_LAYER["dim"]),
+ ("TEXT", ROLE_LAYER["text"]),
+ ]
+ line = await backend.entity_get(result["handles"][0])
+ assert line.properties["start"] == pytest.approx([100.0, 50.0], abs=EPS)
+ assert line.properties["end"] == pytest.approx([110.0, 50.0], abs=EPS)
+ circle = await backend.entity_get(result["handles"][1])
+ assert circle.properties["center"] == pytest.approx([105.0, 55.0], abs=EPS)
+
+
+async def test_an_explicit_layer_overrides_every_role(backend):
+ result = await draw_annotation_prims(
+ backend,
+ (Line((0.0, 0.0), (1.0, 0.0), "dim"), Text((0.0, 2.0), "x", 3.5)),
+ layer="ANNOT",
+ )
+ assert {layer for _type, layer in await _types_and_layers(backend, result["handles"])} == {
+ "ANNOT"
+ }
+
+
+async def test_leader_prims_reuse_the_repository_arrowhead_proportions():
+ line, head = leader_prims((0.0, 0.0), (10.0, 0.0), arrow_size=2.5)
+ assert isinstance(line, Line) and isinstance(head, Poly) and head.closed
+ assert _flat(head.points) == pytest.approx(
+ (0.0, 0.0, 2.5, 2.5 * 0.35, 2.5, -2.5 * 0.35), abs=EPS
+ )
+ with pytest.raises(ValueError) as excinfo:
+ leader_prims((1.0, 1.0), (1.0, 1.0))
+ assert "distinct" in str(excinfo.value)
+
+
+async def test_draw_surface_texture_draws_the_symbol_and_its_leader(backend):
+ result = await draw_surface_texture(
+ backend, at=(40.0, 60.0), leader_to=(20.0, 50.0), ra=3.2, machining="required"
+ )
+ assert result["ok"] is True and result["standard"] == "ISO 21920-1"
+ rows = await _types_and_layers(backend, result["handles"])
+ # 3 vee/bar lines + extension line + the Ra text + leader line + arrowhead.
+ assert [t for t, _ in rows] == [
+ "LINE",
+ "LINE",
+ "LINE",
+ "LINE",
+ "TEXT",
+ "LINE",
+ "LWPOLYLINE",
+ ]
+ apex = await backend.entity_get(result["handles"][0])
+ assert apex.properties["start"] == pytest.approx([40.0, 60.0], abs=EPS)
+ leader = await backend.entity_get(result["handles"][5])
+ assert leader.properties["start"] == pytest.approx([20.0, 50.0], abs=EPS)
+ assert leader.properties["end"] == pytest.approx([40.0, 60.0], abs=EPS)
+
+
+async def test_draw_weld_symbol_refuses_an_unknown_kind_before_touching_the_drawing(backend):
+ before = (await backend.analysis_stats())["total_entities"]
+ with pytest.raises(ValueError):
+ await draw_weld_symbol(backend, at=(0.0, 0.0), kind="seam")
+ assert (await backend.analysis_stats())["total_entities"] == before
+
+
+async def test_draw_weld_symbol_places_the_kink_at_the_given_point(backend):
+ result = await draw_weld_symbol(
+ backend, at=(10.0, 20.0), kind="fillet", size=5, length=50, leader_to=(0.0, 10.0)
+ )
+ assert result["ok"] is True
+ reference = await backend.entity_get(result["handles"][0])
+ assert reference.properties["start"] == pytest.approx([10.0, 20.0], abs=EPS)
+ assert reference.properties["end"][1] == pytest.approx(20.0, abs=EPS)
+ texts = []
+ for handle in result["handles"]:
+ info = await backend.entity_get(handle)
+ if info.type == "TEXT":
+ texts.append(info.properties["text"])
+ assert texts == ["a5", "50"]
diff --git a/tests/test_mech_marks.py b/tests/test_mech_marks.py
new file mode 100644
index 0000000..3c5c01a
--- /dev/null
+++ b/tests/test_mech_marks.py
@@ -0,0 +1,495 @@
+"""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 math
+
+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"]
+
+
+# ── the cut face is the face, not its chords (regression) ───────────────────
+
+from engineering.layers import ENGINEERING_LAYERS # noqa: E402
+from engineering.mech import marks as _marks # noqa: E402
+from engineering.mech.marks import ( # noqa: E402
+ HATCH_FLATTEN_SAGITTA,
+ _flatten_bulge,
+ _polygon_area,
+)
+
+
+async def test_a_bulged_segment_is_expanded_onto_its_real_arc():
+ """A bulge of 1 is a 180 degree arc; its chords must sit on that circle."""
+ interior = _flatten_bulge((40.0, 10.0), (0.0, 10.0), 1.0)
+ assert len(interior) >= 40 # a 0.01 sagitta on r=20 needs ~50 chords
+ for x, y in interior:
+ assert math.hypot(x - 20.0, y - 10.0) == pytest.approx(20.0, abs=1e-9)
+ assert y >= 10.0 # a positive bulge sweeps counter-clockwise, i.e. above
+ # Every chord's sagitta stays inside the declared tolerance.
+ walk = [(40.0, 10.0), *interior, (0.0, 10.0)]
+ for (x1, y1), (x2, y2) in zip(walk, walk[1:], strict=False):
+ chord = math.hypot(x2 - x1, y2 - y1)
+ sagitta = 20.0 - math.sqrt(max(0.0, 400.0 - (chord / 2.0) ** 2))
+ assert sagitta <= HATCH_FLATTEN_SAGITTA + 1e-12
+ assert _flatten_bulge((0.0, 0.0), (10.0, 0.0), 0.0) == []
+
+
+async def _arc_topped_face(backend):
+ """A 40x10 box closed by a 180 degree arc: 400 straight + 628.3 under the arc."""
+ lower = await backend.entity_create_line(0.0, 0.0, 40.0, 0.0, 0.0, 0.0, "GEOMETRY")
+ right = await backend.entity_create_line(40.0, 0.0, 40.0, 10.0, 0.0, 0.0, "GEOMETRY")
+ arc = await backend.entity_create_arc(20.0, 10.0, 20.0, 0.0, 180.0, "GEOMETRY")
+ left = await backend.entity_create_line(0.0, 10.0, 0.0, 0.0, 0.0, 0.0, "GEOMETRY")
+ return [lower.handle, right.handle, arc.handle, left.handle]
+
+
+async def _rectangle(backend):
+ handles = []
+ for start, end in (
+ ((0.0, 0.0), (40.0, 0.0)),
+ ((40.0, 0.0), (40.0, 10.0)),
+ ((40.0, 10.0), (0.0, 10.0)),
+ ((0.0, 10.0), (0.0, 0.0)),
+ ):
+ line = await backend.entity_create_line(start[0], start[1], end[0], end[1], 0.0, 0.0)
+ handles.append(line.handle)
+ return handles
+
+
+async def test_an_arc_bounded_cut_face_is_hatched_on_the_arc_not_its_chord(backend):
+ """The failure README.md names: reading `points` drops the parallel `bulges`.
+
+ Reading only `traced.properties["points"]` hatched the chord polygon -
+ 400.0 of a 1028.3 cut face, 61% of it silently unhatched. The hatch must
+ now measure the face, and the payload must state its own accuracy.
+ """
+ handles = await _arc_topped_face(backend)
+ exact = (await backend.boundary_from_entities(handles))["area"]
+ assert exact == pytest.approx(400.0 + 200.0 * math.pi, abs=1e-6)
+
+ result = await draw_material_hatch(backend, handles=handles, material="steel")
+ assert result["ok"] is True
+ assert result["curved_edges"] == 1
+ assert result["points"] > 4 # not the four-vertex chord polygon
+ assert result["boundary_area"] == pytest.approx(exact, abs=1e-6)
+ # Inside the declared flattening tolerance of the real face, not 61% short.
+ assert result["area"] == pytest.approx(exact, rel=1e-3)
+ assert result["accuracy"] == "flatten_tolerance"
+ assert result["flatten_tolerance"] == HATCH_FLATTEN_SAGITTA
+ # What the drawing holds agrees with what the payload claims.
+ measured = await backend.entity_measure(result["handle"])
+ assert measured["area"] == pytest.approx(result["area"], abs=1e-6)
+
+
+async def test_a_straight_sided_face_is_still_reported_exact(backend):
+ handles = await _rectangle(backend)
+ result = await draw_material_hatch(backend, handles=handles, material="steel")
+ assert result["accuracy"] == "exact"
+ assert result["flatten_tolerance"] is None
+ assert result["curved_edges"] == 0
+ assert result["area"] == pytest.approx(400.0, abs=EPS)
+ assert result["boundary_area"] == pytest.approx(400.0, abs=EPS)
+ assert result["points"] == 4
+
+
+async def test_the_traced_outline_is_removed_and_reported_not_left_on_layer_0(backend):
+ """`boundary_from_entities` *draws* its loop; hatch_material asked for a hatch."""
+ handles = await _rectangle(backend)
+ before = await backend.analysis_stats()
+ result = await draw_material_hatch(backend, handles=handles, material="steel")
+ after = await backend.analysis_stats()
+ # Exactly one new entity: the hatch. No LWPOLYLINE scaffolding survives.
+ assert after["total_entities"] == before["total_entities"] + 1
+ assert after["by_type"].get("LWPOLYLINE", 0) == 0
+ assert result["traced"]["removed"] is True
+ assert result["traced"]["handle"]
+ live = {entity.dxf.handle for entity in backend._doc.modelspace()}
+ assert result["traced"]["handle"] not in live
+
+
+async def test_a_loop_that_encloses_no_area_is_refused_and_leaves_nothing_behind(backend):
+ """Three collinear segments chain into a closed loop of area 0.0.
+
+ The handles branch had no such guard - the 'at least three points' refusal
+ only ever covered `boundary` - so it wrote a HATCH of area 0.0 and returned
+ ok. The traced outline must still be cleaned up on the refusal path.
+ """
+ handles = []
+ for start, end in (
+ ((0.0, 0.0), (10.0, 0.0)),
+ ((10.0, 0.0), (20.0, 0.0)),
+ ((20.0, 0.0), (0.0, 0.0)),
+ ):
+ line = await backend.entity_create_line(start[0], start[1], end[0], end[1], 0.0, 0.0)
+ handles.append(line.handle)
+ before = await backend.analysis_stats()
+ with pytest.raises(ValueError) as excinfo:
+ await draw_material_hatch(backend, handles=handles, material="steel")
+ assert "no area" in str(excinfo.value)
+ after = await backend.analysis_stats()
+ assert after["total_entities"] == before["total_entities"]
+ assert after["by_type"].get("LWPOLYLINE", 0) == 0
+ assert after["by_type"].get("HATCH", 0) == 0
+
+
+async def test_the_polygon_area_helper_is_orientation_blind():
+ square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
+ assert _polygon_area(square) == pytest.approx(100.0, abs=EPS)
+ assert _polygon_area(list(reversed(square))) == pytest.approx(100.0, abs=EPS)
+
+
+# ── the SOURCE block may not quote a lineweight the layer set does not have ──
+
+
+async def test_the_section_line_source_block_quotes_the_layer_set_it_draws_on():
+ """A SOURCE block asserting 0.25 mm while CENTER is 0.18 mm is a fabricated
+ number in the one place this repository refuses to fabricate one."""
+ widths = {name: weight for name, _c, _lt, weight, _d in ENGINEERING_LAYERS}
+ wide, narrow = widths["GEOMETRY"], widths["CENTER"]
+ doc = _marks.__doc__
+ assert f"GEOMETRY is {wide:.2f} mm" in doc
+ assert f"CENTER is {narrow:.2f} mm" in doc
+ assert f"{wide / narrow:.2f}:1" in doc
+
+
+async def test_the_cutting_plane_line_really_lands_on_those_two_lineweights(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
+ )
+ widths = {layer.name: layer.lineweight for layer in await backend.layer_list()}
+ drawn = {(await backend.entity_get(handle)).layer for handle in result["handles"]}
+ assert {ROLE_LAYER["visible"], ROLE_LAYER["center"]} <= drawn
+ # lineweight is reported in 1/100 mm; the SOURCE block quotes millimetres.
+ assert widths[ROLE_LAYER["visible"]] == pytest.approx(50.0, abs=EPS)
+ assert widths[ROLE_LAYER["center"]] == pytest.approx(18.0, abs=EPS)
diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py
index f075973..0eeef2c 100644
--- a/tests/test_tool_packs.py
+++ b/tests/test_tool_packs.py
@@ -7,7 +7,8 @@ essentials and stay in `core`, so `TOOL_PACKS=core` hides the environment
surface and nothing else.
v1.6 tracks B+G add the `mech` pack: SECTION 21's six part-model tools and
-SECTION 22's three standard-parts tools.
+SECTION 22's three standard-parts tools and SECTION 23's five annotation
+symbols - 14 in all.
"""
from __future__ import annotations
@@ -91,6 +92,11 @@ MECH_TOOLS = {
"std_part_list",
"std_part_insert",
"std_feature_draw",
+ "surface_texture",
+ "weld_symbol",
+ "centre_marks",
+ "section_line",
+ "hatch_material",
}
LEAN_SETTINGS_ESSENTIALS = {
@@ -120,7 +126,7 @@ def test_pack_registry_names_real_tools_and_only_them():
assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
assert len(SETTINGS_TOOLS) == 22
- assert len(MECH_TOOLS) == 9
+ assert len(MECH_TOOLS) == 14
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["pid"])
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"])
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index d562a02..213391f 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 (204 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
@@ -540,6 +540,12 @@ async def test_tool_group_sizes_are_unchanged():
SECTION 22 added the standard-parts catalogue (`std_part_list` /
`std_part_insert` / `std_feature_draw`) to the same group, filed under
`mech` rather than under their secondary `query` / `create` tags.
+
+ SECTION 23 added the mechanical annotation symbols (`surface_texture` /
+ `weld_symbol` / `centre_marks` / `section_line` / `hatch_material`). They
+ are tagged `engineering` and `mech`; with `mech` ranked ahead of
+ `engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all,
+ exactly `PACK_TOOL_NAMES["mech"]`), and `engineering` stays at 10.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -556,7 +562,7 @@ async def test_tool_group_sizes_are_unchanged():
"environment": 22,
"layers": 14,
"layouts": 12,
- "mech": 9,
+ "mech": 14,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -568,4 +574,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 213
+ assert sum(sizes.values()) == 218