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