diff --git a/README.md b/README.md
index 6d056a1..ce39366 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests.
+> **v1.5 release snapshot:** 206 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
-204 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+206 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**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 f139c03..18ca298 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -345,6 +345,33 @@ 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",
+ ),
+ ),
# ── Dimensions ──────────────────────────────────────────────────────────
"dimension_aligned": ToolAliases(
acad=("DIMALIGNED", "DAL"),
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index c5c16f1..cdaa104 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": 204,
+ "tools": 206,
"resources": 8,
"prompts": 5,
"groups": 23
@@ -15,7 +15,7 @@
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
- "engineering": 10,
+ "engineering": 12,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
@@ -2333,6 +2333,20 @@
"destructiveHint": false
}
},
+ {
+ "name": "surface_texture",
+ "group": "engineering",
+ "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",
@@ -2903,6 +2917,20 @@
"title": "Set Viewport Scale",
"destructiveHint": false
}
+ },
+ {
+ "name": "weld_symbol",
+ "group": "engineering",
+ "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..94328c5
--- /dev/null
+++ b/engineering/mech/annotate.py
@@ -0,0 +1,586 @@
+"""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 typing import TYPE_CHECKING
+
+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",
+ "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, the other-side symbol
+ hangs below the identification line.
+ """
+ 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
+
+ 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, ident_y, -1.0, h))
+
+ if size_text:
+ prims.append(Text((x_size, 0.25 * h), size_text, h))
+ if right_text:
+ prims.append(Text((x_right, 0.25 * h), 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,
+ ),
+ )
+
+
+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.
+ """
+ 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]))
+
+ handles: list[str] = []
+ for prim in placed:
+ target = layer or ROLE_LAYER[prim.role]
+ 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)}
+
+
+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["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["kind"] = kind
+ result["side"] = side
+ return result
diff --git a/server.py b/server.py
index 1635d7e..85d820b 100644
--- a/server.py
+++ b/server.py
@@ -8123,6 +8123,187 @@ async def drawing_properties_set(
return await _backend(ctx).drawing_properties_set(summary, custom)
+# ---------------------------------------------------------------------------
+# ── SECTION 23: Mechanical Annotation (2 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,
+ )
+
+
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/test_mech_annotate.py b/tests/test_mech_annotate.py
new file mode 100644
index 0000000..a05140d
--- /dev/null
+++ b/tests/test_mech_annotate.py
@@ -0,0 +1,400 @@
+"""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 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_omits_the_identification_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 V below the identification offset.
+ vees = [p for p in prims if isinstance(p, Poly) and len(p.points) == 3 and not p.closed]
+ assert len(vees) == 2
+ assert vees[0].points[1][1] == pytest.approx(0.0, abs=EPS)
+ assert vees[1].points[1][1] == pytest.approx(-0.4 * 3.5, 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_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
+
+
+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_tool_registry.py b/tests/test_tool_registry.py
index 4b55428..d65fc77 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 (206 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
@@ -529,6 +529,11 @@ async def test_tool_group_sizes_are_unchanged():
their secondary tags; those counts returned to their pre-track-E values
when the section tags landed.) Every other number here has been unchanged
since the snapshot was taken.
+
+ `engineering` moved 10 -> 12 when v1.6's SECTION 23 opened with
+ `surface_texture` / `weld_symbol`; both are tagged `engineering` and `mech`,
+ and `mech` is not in `_GROUP_TAG_PRIORITY`, so they file under
+ `engineering` rather than opening a group of their own.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -538,7 +543,7 @@ async def test_tool_group_sizes_are_unchanged():
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
- "engineering": 10,
+ "engineering": 12,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
@@ -556,4 +561,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 204
+ assert sum(sizes.values()) == 206