From a4ce46ba9b048ce25ecbc0926413a89176402596 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 18:55:57 +0300 Subject: [PATCH 1/4] feat(mech): SECTION 23 opens with surface_texture and weld_symbol ISO 1302:2002's seven transcribed symbol-proportion rows (text height -> line width d', short-leg height H1, minimum height H2) drive the 60 deg vee, the machining-required bar at H1, the machining-prohibited circle inscribed in the vee, and the all-around circle at the kink; the Ra/Rz values, the process, the lay symbol and the machining allowance land around the extension line, which grows with the widest of them. A text height outside the seven rows is refused by name rather than interpolated, and ISO 1302 is accepted as an alias designation for drawings still issued under it - the graphical symbol is the same one. ISO 2553:2019's reference line, dashed identification line (omitted for a symmetrical weld, as the standard permits), size written before the symbol, length and pitch written after it, field-weld flag, all-around circle and process tail. Six elementary symbols are transcribed - square, single-V, single-bevel, single-U, single-J, fillet. The rest of the elementary table is deliberately not shipped: a weld kind outside the six is refused with the list of the six, because a guessed weld symbol is a wrong instruction to a welder. Both tools place the symbol on a plain leader - a line plus an arrowhead with the proportions the backend's own mleader already draws - rather than through leader_create_mleader, which refuses empty text and would hang an MTEXT label off every symbol. Proportions neither standard dimensions (the all-around circles, the flag, the identification line's dashes, the text pitch and the character width estimate) are named constants documented as this implementation's declared choices, never as transcribed values. No new ActiveX member is called: both tools compose LINE / ARC / CIRCLE / LWPOLYLINE / TEXT through the entity-creation contracts both engines already implement, so no live COM probe was needed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 4 +- discovery/aliases.py | 27 ++ docs/tool-inventory.json | 32 +- engineering/mech/annotate.py | 586 +++++++++++++++++++++++++++++++++++ server.py | 181 +++++++++++ tests/test_mech_annotate.py | 400 ++++++++++++++++++++++++ tests/test_tool_registry.py | 11 +- 7 files changed, 1234 insertions(+), 7 deletions(-) create mode 100644 engineering/mech/annotate.py create mode 100644 tests/test_mech_annotate.py 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 From c607e75b75134112bf0e2ef0731f7dfc67a27a91 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:15:33 +0300 Subject: [PATCH 2/4] fix(task-10): ensure annotation layers live, and put the weld symbols and their dimensions where ISO 2553 puts them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three blocking review findings in engineering/mech/annotate.py. 1. draw_annotation_prims assigned ROLE_LAYER[role] without creating the layer. EzdxfBackend._apply_attrs adds a missing layer; ActiveX does not. MEASURED live on AutoCAD 2026 (probe: GetActiveObject, Documents.Add(), doc.Close(False) in finally): on a scratch document whose layer table is ['0'], draw_surface_texture(ComBackend(), at=(0,0), ra=3.2, machining='required', lay='perpendicular', allowance=0.5) raised "AutoCAD COM error (-0x7ffdfff7)" and left one orphan AcDbLine on layer 0 — a partial write with no rollback, on exactly the foreign-drawing path the spec names (drawing_open runs no bootstrap). The same probe after the fix: {'ok': True, count 6, 'layers_created': ['DIM','TEXT']}, model space ['AcDbLine' x4 on DIM, 'AcDbText' x2 on TEXT], layer table ['0','DIM', 'TEXT']. New helper ensure_annotation_layers() creates every target layer up front, before the first entity, with its ENGINEERING_LAYERS colour / linetype / lineweight; the result reports layers_created. 2. side='both' drew the two elementary symbols 0.4h apart — the other-side one still stood on the (correctly omitted) identification line, so a double-V butt weld came out as two detached halves that were not even symmetric about the reference line. ISO 2553 places them back-to-back on the continuous reference line: the other-side baseline is now 0.0 when there is no identification line. tests/test_mech_annotate.py asserted the wrong apex (-0.4*h), so the gate certified the defect; that test is rewritten to assert the mirror pair, plus a double-fillet case. 3. For side='other' the size and length were written above the reference line while the symbol hung below the identification line — 'a6' sat where an arrow-side throat dimension sits, on a joint with no arrow-side weld. Both texts now sit beside their own symbol (0.25h clear of the line it stands on, on the symbol's side). New test covers side='other' with size and length, and pins arrow/both unchanged. All five new or rewritten tests fail against the pre-fix module (the layer one with the measured 'Key not found' refusal, via a fake that models only that measured behaviour) and pass after. tests/test_mech_annotate.py: 29 passed. Full suite: 3326 passed, 6 pre-existing failures unrelated to this change (verified identical on the branch tip with the change stashed), test_release_consistency.py::test_document_numbers_match_the_tree deselected as instructed. No tool registration changed, so docs/tool-inventory.json regenerates byte-identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/annotate.py | 98 ++++++++++++++++++++--- tests/test_mech_annotate.py | 151 ++++++++++++++++++++++++++++++++++- 2 files changed, 236 insertions(+), 13 deletions(-) diff --git a/engineering/mech/annotate.py b/engineering/mech/annotate.py index 94328c5..9b1fb63 100644 --- a/engineering/mech/annotate.py +++ b/engineering/mech/annotate.py @@ -45,8 +45,10 @@ weld annotation. from __future__ import annotations import math +from collections.abc import Iterable from typing import TYPE_CHECKING +from engineering.layers import ENGINEERING_LAYERS from engineering.mech.primitives import ( ROLE_LAYER, Arc, @@ -73,6 +75,7 @@ __all__ = [ "draw_annotation_prims", "draw_surface_texture", "draw_weld_symbol", + "ensure_annotation_layers", "leader_prims", "surface_texture_prims", "symbol_proportions", @@ -312,8 +315,18 @@ def weld_symbol_prims( 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. + arrow-side symbol stands on the reference line and the other-side symbol + hangs below the identification line - except for a symmetrical weld, where + ISO 2553 places the two elementary symbols back-to-back on the continuous + reference line itself (an X butt weld is one symbol, not two detached + halves), so the lower one hangs from y = 0 and the pair is symmetric about + the reference line. + + The size and the length/pitch are written beside the symbol they belong + to - size to its left, length to its right - which for an other-side weld + means beside the identification line, not above the reference line: a + throat dimension written on the arrow side of a joint with no arrow-side + weld tells a welder the wrong thing. """ if kind not in WELD_KINDS: raise ValueError( @@ -360,15 +373,24 @@ def weld_symbol_prims( prims.append(Line((x, ident_y), (x_end, ident_y), "dim")) x = x_end + gap + # A symmetrical weld has no identification line, so its other-side symbol + # stands on the reference line; otherwise it stands on the identification + # line. `sense=-1` then grows it downward from that baseline. + other_baseline = 0.0 if side == "both" else ident_y if side in ("arrow", "both"): prims.extend(_elementary_prims(kind, x_symbol, 0.0, 1.0, h)) if side in ("other", "both"): - prims.extend(_elementary_prims(kind, x_symbol, ident_y, -1.0, h)) + prims.extend(_elementary_prims(kind, x_symbol, other_baseline, -1.0, h)) + # The dimensions sit beside their own symbol: 0.25h clear of the line the + # symbol stands on, on the symbol's side of it. For "both" that is the + # arrow-side symbol, which is where ISO 2553 writes a symmetrical weld's + # single set of dimensions. + text_y = ident_y - 1.25 * h if side == "other" else 0.25 * h if size_text: - prims.append(Text((x_size, 0.25 * h), size_text, h)) + prims.append(Text((x_size, text_y), size_text, h)) if right_text: - prims.append(Text((x_right, 0.25 * h), right_text, h)) + prims.append(Text((x_right, text_y), right_text, h)) if field_weld: top = FLAG_POLE_FACTOR * h @@ -442,6 +464,48 @@ def leader_prims( ) +#: The layer definitions `ROLE_LAYER` names, so a layer this module creates +#: gets the colour/linetype/lineweight `drawing_new` would have given it +#: instead of a bare white Continuous one. +_LAYER_DEFS: dict[str, tuple[int, str, float]] = { + name: (color, linetype, lineweight) + for name, color, linetype, lineweight, _desc in ENGINEERING_LAYERS +} + + +async def ensure_annotation_layers( + backend: AutoCADBackend, names: Iterable[str] +) -> tuple[str, ...]: + """Create whichever of `names` the drawing does not have yet; return those. + + MEASURED on AutoCAD 2026: assigning `entity.Layer = "DIM"` on a drawing + whose layer table is `['0']` raises `-0x7ffdfff7 ('Key not found')` - + ActiveX will not create the layer for you. The headless engine will + (`EzdxfBackend._apply_attrs` calls `doc.layers.add`), so without this step + the same annotation silently succeeds headless and dies live on the first + entity, having already written the ones before it. `drawing_open` runs no + bootstrap, so annotating a foreign drawing - the case the spec names - is + exactly when the layer is missing. Ensuring every target layer up front, + before any entity is created, is what makes the two engines agree and what + keeps a refusal from leaving half a symbol behind. + """ + wanted: list[str] = [] + for name in names: + if name not in wanted: + wanted.append(name) + if not wanted: + return () + existing = {lyr.name.lower() for lyr in await backend.layer_list()} + created: list[str] = [] + for name in wanted: + if name.lower() in existing: + continue + color, linetype, lineweight = _LAYER_DEFS.get(name, (7, "Continuous", 0.25)) + await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight) + created.append(name) + return tuple(created) + + async def draw_annotation_prims( backend: AutoCADBackend, prims, @@ -454,7 +518,10 @@ async def draw_annotation_prims( 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. + `ROLE_LAYER[role]` unless `layer` overrides it, and every target layer is + created first (`ensure_annotation_layers`) - live AutoCAD refuses an + assignment to a layer that is not in the table, and a foreign drawing that + was merely opened has no engineering layers at all. """ placed = tuple(prims) for prim in placed: @@ -467,9 +534,11 @@ async def draw_annotation_prims( placed = rotate(placed, float(rotation)) placed = translate(placed, float(at[0]), float(at[1])) + targets = [layer or ROLE_LAYER[prim.role] for prim in placed] + created = await ensure_annotation_layers(backend, targets) + handles: list[str] = [] - for prim in placed: - target = layer or ROLE_LAYER[prim.role] + for prim, target in zip(placed, targets, strict=True): if isinstance(prim, Line): info = await backend.entity_create_line( prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], 0.0, 0.0, target @@ -496,7 +565,12 @@ async def draw_annotation_prims( 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)} + return { + "ok": True, + "handles": handles, + "count": len(handles), + "layers_created": list(created), + } async def draw_surface_texture( @@ -538,6 +612,9 @@ async def draw_surface_texture( drawn = await draw_annotation_prims(backend, leader, layer=layer) result["handles"].extend(drawn["handles"]) result["count"] = len(result["handles"]) + result["layers_created"].extend( + name for name in drawn["layers_created"] if name not in result["layers_created"] + ) result["standard"] = standard return result @@ -581,6 +658,9 @@ async def draw_weld_symbol( drawn = await draw_annotation_prims(backend, leader, layer=layer) result["handles"].extend(drawn["handles"]) result["count"] = len(result["handles"]) + result["layers_created"].extend( + name for name in drawn["layers_created"] if name not in result["layers_created"] + ) result["kind"] = kind result["side"] = side return result diff --git a/tests/test_mech_annotate.py b/tests/test_mech_annotate.py index a05140d..b1ea98a 100644 --- a/tests/test_mech_annotate.py +++ b/tests/test_mech_annotate.py @@ -8,6 +8,7 @@ document through the `backend` fixture. No screenshots anywhere. from __future__ import annotations import math +import types import pytest @@ -199,16 +200,38 @@ async def test_the_reference_line_comes_first_and_the_identification_line_is_das assert dashes[-1].p2[0] <= length + EPS -async def test_a_symmetrical_weld_omits_the_identification_line(): +async def test_a_symmetrical_weld_meets_back_to_back_on_the_reference_line(): + """ISO 2553: no identification line, and the two elementary symbols share it. + + The earlier version of this test asserted the lower V apex at -0.4h, i.e. + on the (omitted) identification line, which drew a double-V butt weld as + two detached halves that were not even symmetric about the reference line. + """ prims = weld_symbol_prims(kind="v", side="both", height=3.5) assert not [ p for p in prims if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS) ] - # One V above the reference line, one V below the identification offset. + # One V above the reference line, one below it, both standing on y = 0. vees = [p for p in prims if isinstance(p, Poly) and len(p.points) == 3 and not p.closed] assert len(vees) == 2 - 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) + upper, lower = vees + assert upper.points[1][1] == pytest.approx(0.0, abs=EPS) + assert lower.points[1][1] == pytest.approx(0.0, abs=EPS) + # Mirror images of each other about y = 0, same x. + assert _flat(lower.points) == pytest.approx( + tuple(v if i % 2 == 0 else -v for i, v in enumerate(_flat(upper.points))), abs=EPS + ) + ys = [y for _x, y in upper.points + lower.points] + assert (min(ys), max(ys)) == pytest.approx((-3.5, 3.5), abs=EPS) + + +async def test_a_symmetrical_fillet_weld_is_the_double_fillet_not_two_halves(): + prims = weld_symbol_prims(kind="fillet", side="both", height=3.5) + triangles = [p for p in prims if isinstance(p, Poly) and p.closed] + assert len(triangles) == 2 + x0 = triangles[0].points[0][0] + assert _flat(triangles[0].points) == pytest.approx((x0, 0.0, x0, 3.5, x0 + 3.5, 0.0), abs=EPS) + assert _flat(triangles[1].points) == pytest.approx((x0, 0.0, x0, -3.5, x0 + 3.5, 0.0), abs=EPS) async def test_the_fillet_triangle_stands_on_the_line_with_its_leg_on_the_left(): @@ -228,6 +251,45 @@ async def test_the_other_side_symbol_hangs_below_the_identification_line(): ) +async def test_other_side_dimensions_are_written_beside_the_other_side_symbol(): + """ISO 2553 writes the size left of the symbol and the length right of it. + + For an other-side weld the symbol hangs from the identification line, so + both texts belong there too. Written above the reference line instead, + `a6` sits exactly where an arrow-side throat dimension sits - on a joint + that has no arrow-side weld. + """ + prims = weld_symbol_prims(kind="fillet", side="other", size=6, length=50, height=3.5) + triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0] + x_symbol = triangle.points[0][0] + texts = [p for p in prims if isinstance(p, Text)] + assert [t.text for t in texts] == ["a6", "50"] + # Beside the symbol: size to its left, length to its right. + assert texts[0].at[0] < x_symbol + assert texts[1].at[0] > x_symbol + 3.5 + # Below the reference line, 0.25h clear of the identification line, so the + # whole text body lies between the identification line and the symbol's + # far edge - never above y = 0. + ident_y = -0.4 * 3.5 + for t in texts: + assert t.at[1] == pytest.approx(ident_y - 1.25 * 3.5, abs=EPS) + assert t.at[1] + t.height < 0.0 + # The arrow side is unchanged: its dimensions stay above the reference line. + arrow = [ + p + for p in weld_symbol_prims(kind="fillet", side="arrow", size=6, length=50, height=3.5) + if isinstance(p, Text) + ] + assert all(t.at[1] == pytest.approx(0.25 * 3.5, abs=EPS) for t in arrow) + # A symmetrical weld carries one set, beside its arrow-side symbol. + both = [ + p + for p in weld_symbol_prims(kind="fillet", side="both", size=6, length=50, height=3.5) + if isinstance(p, Text) + ] + assert all(t.at[1] == pytest.approx(0.25 * 3.5, abs=EPS) for t in both) + + async def test_the_u_symbol_is_a_half_circle_between_two_uprights(): prims = weld_symbol_prims(kind="u", height=3.5) arc = [p for p in prims if isinstance(p, Arc)][0] @@ -304,6 +366,87 @@ from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tes from engineering.mech.primitives import ROLE_LAYER # noqa: E402 +class _StrictLayerBackend: + """A backend that refuses an entity on a layer the drawing does not have. + + MEASURED on AutoCAD 2026: `Documents.Add()` gives a layer table of `['0']`, + and `entity.Layer = "DIM"` on it raises + `AutoCAD COM error (-0x7ffdfff7) ('Key not found')` - live ActiveX never + auto-creates the layer, while `EzdxfBackend._apply_attrs` does. This fake + models that measured refusal (and only it: it has no member the real + `AutoCADBackend` lacks), so the headless suite fails the way the live + engine fails. Before the fix it recorded one LINE and then raised; the + drawing kept that orphan, which is the partial write the fix removes. + """ + + def __init__(self, layers=("0",)): + self.layers = {name.lower(): name for name in layers} + self.calls: list[tuple] = [] + self._next = 0 + + async def layer_list(self): + self.calls.append(("layer_list",)) + return [types.SimpleNamespace(name=name) for name in self.layers.values()] + + async def layer_create(self, name, color=7, linetype="Continuous", lineweight=-3): + self.calls.append(("layer_create", name, color, linetype, lineweight)) + self.layers[name.lower()] = name + return types.SimpleNamespace(name=name) + + def _entity(self, kind, layer): + if layer is not None and layer.lower() not in self.layers: + raise RuntimeError(f"AutoCAD COM error (-0x7ffdfff7) ('Key not found'): {layer}") + self.calls.append((kind, layer)) + self._next += 1 + return types.SimpleNamespace(handle=f"{self._next:X}") + + async def entity_create_line(self, x1, y1, x2, y2, z1=0.0, z2=0.0, layer=None, **kw): + return self._entity("LINE", layer) + + async def entity_create_circle(self, cx, cy, radius, layer=None, **kw): + return self._entity("CIRCLE", layer) + + async def entity_create_arc(self, cx, cy, radius, start_angle, end_angle, layer=None, **kw): + return self._entity("ARC", layer) + + async def entity_create_polyline(self, points, closed=False, layer=None, **kw): + return self._entity("LWPOLYLINE", layer) + + async def entity_create_text(self, text, x, y, height=2.5, rotation=0.0, layer=None, **kw): + return self._entity("TEXT", layer) + + +async def test_every_target_layer_is_created_before_the_first_entity(): + backend = _StrictLayerBackend() + result = await draw_surface_texture( + backend, at=(0.0, 0.0), ra=3.2, machining="required", lay="perpendicular", allowance=0.5 + ) + assert result["ok"] is True and result["count"] == 6 + assert result["layers_created"] == [ROLE_LAYER["dim"], ROLE_LAYER["text"]] + # Nothing is drawn until every layer the symbol needs exists. + first_entity = next( + i + for i, call in enumerate(backend.calls) + if call[0] != "layer_list" and call[0] != "layer_create" + ) + assert {call[1] for call in backend.calls[:first_entity] if call[0] == "layer_create"} == { + ROLE_LAYER["dim"], + ROLE_LAYER["text"], + } + # Created with their ENGINEERING_LAYERS definition, not a bare default. + dim_create = next(c for c in backend.calls if c[0] == "layer_create" and c[1] == "DIM") + assert dim_create[2:] == (2, "Continuous", 0.18) + + +async def test_a_drawing_that_already_has_the_layers_is_not_rewritten(): + backend = _StrictLayerBackend(layers=("0", "dim", "TEXT")) + result = await draw_weld_symbol( + backend, at=(0.0, 0.0), kind="fillet", size=5, length=50, leader_to=(-10.0, -10.0) + ) + assert result["layers_created"] == [] + assert not [c for c in backend.calls if c[0] == "layer_create"] + + async def _types_and_layers(backend, handles): rows = [] for handle in handles: From 64a57d74a00b10364a978806c3dd47fdb1cf3f0a Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:25:13 +0300 Subject: [PATCH 3/4] feat(mech): SECTION 23 completes with centre_marks, section_line, hatch_material centre_marks draws ISO 128-23 centre marks or full centre lines and reads each circle's real centre and radius back out of the drawing through entity_get instead of being told them - a radius restated from memory is the silent wrong number this repository keeps refusing to ship. A handle that is not a CIRCLE or ARC, and a circle in a plane tilted out of WCS XY (which has no radius in this frame), are both refused by handle before anything is written. The dash proportions are not transcribed here: the prims are plain lines on CENTER, whose linetype already carries them. section_line draws the ISO 128-40 cutting-plane line - wide segments at the ends on GEOMETRY, the thin long-dash-dot middle on CENTER, an arrow at each end whose head sits on the line end and points the way the section is viewed, the same letter beyond both arrows - and returns the five-key plane dict {p1, p2, label, direction, style} that mech_view_add(kind= "section") consumes, so the label on the plan and the label on the view are the same string by construction. A viewing direction parallel to the plane is refused rather than drawn. hatch_material hatches through standards.materials.hatch_for, so the ISO 128-50 map and its refusal stay in one place; scale multiplies the material's own pattern scale and angle replaces its angle. An unknown material is refused with the list before any entity is written. DEVIATION - one defect in the plan's own draw_material_hatch, fixed here with two tests that fail against the plan's code. It called entity_create_hatch with ROLE_LAYER["hatch"] without creating that layer, the same omission review found in Task 10's draw_annotation_prims. MEASURED live on AutoCAD 2026 (probe: GetActiveObject, Documents.Add(), doc.Close(False) in a finally): on a scratch document whose layer table is ['0'], msp.AddHatch(0, "ANSI31", False) + AppendOuterLoop + Evaluate() leaves the AcDbHatch in model space, and the very next statement hatch.Layer = "HATCH" - which is the one line _apply_entity_attrs runs - raises com_error (-2147352567, ..., 'Key not found', ..., -2145386476); model space then still reports [('AcDbHatch', '0')] and the layer table is still ['0'], so the refusal leaves an orphan hatch on layer 0. After doc.Layers.Add("HATCH") the same assignment succeeds. drawing_open runs no bootstrap, which is exactly when a cut face has no HATCH layer to land on. draw_material_hatch now calls ensure_annotation_layers before creating the hatch and reports layer / layers_created, as draw_annotation_prims does. The whole task re-measured live afterwards through ComBackend on a fresh scratch document (layer table ['0']): hatch_material -> {'ok': True, 'handle': '82', 'pattern': 'ANSI31', 'layer': 'HATCH', 'layers_created': ['HATCH']} with the entity read back as HATCH on HATCH; centre_marks on a live CIRCLE r=12.5 -> sources [{'handle': '84', 'center': [80.0, 0.0], 'radius': 12.5}]; section_line -> 9 entities and the five-key plane; layer table after ['0', 'CENTER', 'GEOMETRY', 'HATCH', 'TEXT']; document closed. tests/test_mech_marks.py: 17 passed. Gates: ruff check clean, inventory regenerated to 209 tools, test_tool_registry (engineering 12 -> 15, total 206 -> 209), test_discovery_aliases, test_tool_inventory, test_capability_contract and test_release_consistency all pass. Full suite: 3343 passed, the 6 pre-existing failures verified identical with this change stashed, and test_release_consistency.py::test_document_numbers_match_the_tree deselected as Global Constraints instructs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 4 +- discovery/aliases.py | 32 +++ docs/tool-inventory.json | 46 +++- engineering/mech/marks.py | 407 ++++++++++++++++++++++++++++++++++++ server.py | 162 +++++++++++++- tests/test_mech_marks.py | 336 +++++++++++++++++++++++++++++ tests/test_tool_registry.py | 13 +- 7 files changed, 989 insertions(+), 11 deletions(-) create mode 100644 engineering/mech/marks.py create mode 100644 tests/test_mech_marks.py diff --git a/README.md b/README.md index ce39366..22f0776 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 206 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> **v1.5 release snapshot:** 209 tools · 8 resources · 5 prompt templates · 3284 collected tests. > 204 is the **registered** count; a default install advertises 199 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. @@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -206 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +209 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index 18ca298..a225c53 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -372,6 +372,38 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "kose kaynagi", ), ), + "centre_marks": ToolAliases( + acad=("CENTERMARK", "CENTERLINE", "DIMCENTER"), + synonyms=( + "centre mark", + "center mark", + "centre line", + "center line of a hole", + "cross at the centre", + "eksen cizgisi", + "merkez isareti", + ), + ), + "section_line": ToolAliases( + acad=("SECTIONPLANE",), + synonyms=( + "cutting plane line", + "section line", + "section a-a", + "where to cut the part", + "view direction arrows", + "kesit cizgisi", + ), + ), + "hatch_material": ToolAliases( + synonyms=( + "material hatch", + "hatch as cast iron", + "section hatching for steel", + "hatch pattern for a material", + "malzeme taramasi", + ), + ), # ── Dimensions ────────────────────────────────────────────────────────── "dimension_aligned": ToolAliases( acad=("DIMALIGNED", "DAL"), diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index cdaa104..268e2ed 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 206, + "tools": 209, "resources": 8, "prompts": 5, "groups": 23 @@ -15,7 +15,7 @@ "corner_ops": 4, "dimensions": 5, "drawing": 11, - "engineering": 12, + "engineering": 15, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, @@ -343,6 +343,20 @@ "destructiveHint": true } }, + { + "name": "centre_marks", + "group": "engineering", + "cost": "mutate", + "summary": "Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.", + "tags": [ + "engineering", + "mech" + ], + "annotations": { + "title": "Centre Marks (ISO 128-23)", + "destructiveHint": false + } + }, { "name": "construction_clear", "group": "premium", @@ -1592,6 +1606,20 @@ "destructiveHint": false } }, + { + "name": "hatch_material", + "group": "engineering", + "cost": "mutate", + "summary": "Hatch a region with the ISO 128-50 section pattern for a named material.", + "tags": [ + "engineering", + "mech" + ], + "annotations": { + "title": "Material Hatch (ISO 128-50)", + "destructiveHint": false + } + }, { "name": "hatch_set_gradient", "group": "entity_creation", @@ -2212,6 +2240,20 @@ "readOnlyHint": true } }, + { + "name": "section_line", + "group": "engineering", + "cost": "mutate", + "summary": "Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.", + "tags": [ + "engineering", + "mech" + ], + "annotations": { + "title": "Section Line (ISO 128-40)", + "destructiveHint": false + } + }, { "name": "selection_filter", "group": "entity_query", diff --git a/engineering/mech/marks.py b/engineering/mech/marks.py new file mode 100644 index 0000000..a4eff9a --- /dev/null +++ b/engineering/mech/marks.py @@ -0,0 +1,407 @@ +"""Centre marks, the cutting-plane line and the material hatch. + +Pure geometry: these functions return primitive descriptions from +`engineering.mech.primitives`; the `draw_*` functions are the only async part +and the only part that touches a backend. + +SOURCE - centre marks and centre lines: ISO 128-23, "Technical drawings - +General principles of presentation - Lines on construction drawings", which +draws a centre line as a long-dash-dotted line that overruns the feature's +outline by a short amount. The *dash pattern itself is not written here*: the +prims are plain LINE entities on the CENTER layer, whose CENTER linetype +(bootstrapped by `drawing_new`) supplies the standard's dash proportions. That +is deliberate - transcribing a dash table this module cannot verify would put +an invented pattern next to a real one. + +SOURCE - the cutting-plane line: ISO 128-40, "Technical drawings - General +principles of presentation - Basic conventions for cuts and sections": a +long-dash-dotted line that is *wide* at its ends and at every change of +direction, an arrow at each end pointing in the direction of viewing, and the +same capital letter at both ends. Line width is a layer property here, so the +wide ends are emitted on the `visible` role (GEOMETRY, 0.50 mm) and the thin +middle on the `center` role (CENTER, 0.25 mm) - the ISO appearance, expressed +in the role vocabulary this repository already has. + +DECLARED, not transcribed: ISO 128-40 says the ends are thickened and that +arrows are placed there; it does not dimension them. The end-segment length, +the arrow shaft length, the arrowhead size and the label offset below are this +implementation's stated choices, expressed in text heights. + +The material hatch is not authored here at all: it comes from +`engineering.mech.standards.materials.hatch_for`, which carries the ISO 128-50 +map and its own refusal. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Line, Poly, Prim, Text + +if TYPE_CHECKING: # pragma: no cover - typing only + from backends.base import AutoCADBackend + +__all__ = [ + "CENTRE_STYLES", + "SECTION_STYLES", + "centre_mark_prims", + "draw_centre_marks", + "draw_material_hatch", + "draw_section_line", + "section_line_prims", + "section_plane", +] + +CENTRE_STYLES: tuple[str, ...] = ("mark", "lines") + +# The four styles `views.build_view(kind="section", style=...)` accepts; the +# dict this module returns is fed straight to it. +SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved") + +# DECLARED layout constants for the cutting-plane line, in text heights. +END_SEGMENT_FACTOR = 2.0 # wide segment at each end +ARROW_SHAFT_FACTOR = 2.0 # arrow shaft length, perpendicular to the plane +ARROW_HEAD_FACTOR = 0.8 # arrowhead length (half-width is 0.35 of it, as elsewhere) +LABEL_OFFSET_FACTOR = 0.7 # gap between the arrow tail and the label + + +def centre_mark_prims( + center: tuple[float, float], + radius: float, + *, + style: str = "mark", + extension: float = 3.0, +) -> tuple[Prim, ...]: + """A centre mark or a pair of full centre lines for a circular feature. + + `style="mark"` draws the short cross AutoCAD's DIMCEN draws: arms of + `extension` either side of the centre. `style="lines"` draws the two full + centre lines, overrunning the circle by `extension` at each end, which is + ISO 128-23's short overrun of the outline. + """ + if style not in CENTRE_STYLES: + raise ValueError( + f"centre style {style!r} is unknown; use one of {', '.join(CENTRE_STYLES)}." + ) + ext = float(extension) + if not math.isfinite(ext) or ext <= 0.0: + raise ValueError(f"extension must be a positive finite number; got {extension!r}.") + cx, cy = float(center[0]), float(center[1]) + if style == "mark": + arm = ext + else: + r = float(radius) + if not math.isfinite(r) or r <= 0.0: + raise ValueError( + f"style='lines' needs the feature's radius to know how far to overrun it; " + f"got radius={radius!r}." + ) + arm = r + ext + return ( + Line((cx - arm, cy), (cx + arm, cy), "center"), + Line((cx, cy - arm), (cx, cy + arm), "center"), + ) + + +def _unit(vx: float, vy: float, what: str) -> tuple[float, float]: + length = math.hypot(vx, vy) + if length <= 1e-12: + raise ValueError(f"{what} must be a non-zero vector; got ({vx:g}, {vy:g}).") + return vx / length, vy / length + + +def section_line_prims( + p1: tuple[float, float], + p2: tuple[float, float], + *, + label: str = "A", + direction: tuple[float, float] = (0.0, -1.0), + height: float = 5.0, +) -> tuple[Prim, ...]: + """The ISO 128-40 cutting-plane line: wide ends, arrows, the letter at both ends. + + `direction` is the direction of viewing and must not be parallel to the + plane. The arrowhead's tip sits on the end of the cutting-plane line and + its shaft extends away from the part, so the head reads as pointing the way + the section is viewed; the letter goes beyond the tail. + """ + if not str(label).strip(): + raise ValueError("a cutting-plane line needs a label; ISO 128-40 letters both ends.") + h = float(height) + if not math.isfinite(h) or h <= 0.0: + raise ValueError(f"height must be a positive finite number; got {height!r}.") + x1, y1 = float(p1[0]), float(p1[1]) + x2, y2 = float(p2[0]), float(p2[1]) + ux, uy = _unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)") + dx, dy = _unit(float(direction[0]), float(direction[1]), "direction") + if abs(ux * dy - uy * dx) < 1e-6: + raise ValueError( + f"direction ({dx:g}, {dy:g}) is parallel to the cutting plane; the viewing " + "direction must be perpendicular to it." + ) + + span = math.hypot(x2 - x1, y2 - y1) + end = END_SEGMENT_FACTOR * h + prims: list[Prim] = [] + if span <= 2.0 * end + 1e-9: + prims.append(Line((x1, y1), (x2, y2), "visible")) + else: + a = (x1 + ux * end, y1 + uy * end) + b = (x2 - ux * end, y2 - uy * end) + prims.append(Line((x1, y1), a, "visible")) + prims.append(Line(b, (x2, y2), "visible")) + prims.append(Line(a, b, "center")) + + shaft = ARROW_SHAFT_FACTOR * h + head = ARROW_HEAD_FACTOR * h + px, py = -dy, dx + text = str(label).strip() + for tip in ((x1, y1), (x2, y2)): + tail = (tip[0] - dx * shaft, tip[1] - dy * shaft) + prims.append(Line(tail, tip, "visible")) + base = (tip[0] - dx * head, tip[1] - dy * head) + prims.append( + Poly( + ( + tip, + (base[0] + px * head * 0.35, base[1] + py * head * 0.35), + (base[0] - px * head * 0.35, base[1] - py * head * 0.35), + ), + True, + "visible", + ) + ) + for tip in ((x1, y1), (x2, y2)): + offset = shaft + LABEL_OFFSET_FACTOR * h + prims.append(Text((tip[0] - dx * offset, tip[1] - dy * offset), text, h)) + return tuple(prims) + + +def section_plane( + p1: tuple[float, float], + p2: tuple[float, float], + *, + label: str = "A", + direction: tuple[float, float] = (0.0, -1.0), + style: str = "full", +) -> dict: + """The plane definition `mech_view_add(kind="section")` consumes. + + Exactly the five keys the plan pins as SECTION_PLANE, in that order, with + `direction` normalised to a unit vector. Returning it from the tool that + draws the cutting-plane line is what makes the label on the plan and the + label on the section view the same string by construction. + """ + if style not in SECTION_STYLES: + raise ValueError( + f"section style {style!r} is unknown; use one of {', '.join(SECTION_STYLES)}." + ) + text = str(label).strip() + if not text: + raise ValueError("a section plane needs a label; ISO 128-40 letters both ends.") + x1, y1 = float(p1[0]), float(p1[1]) + x2, y2 = float(p2[0]), float(p2[1]) + _unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)") + dx, dy = _unit(float(direction[0]), float(direction[1]), "direction") + return { + "p1": (x1, y1), + "p2": (x2, y2), + "label": text, + "direction": (dx, dy), + "style": style, + } + + +# ── the async layer ───────────────────────────────────────────────────────── + +_CIRCULAR_TYPES = ("CIRCLE", "ARC") + + +async def draw_centre_marks( + backend: AutoCADBackend, + *, + handles: list[str] | None = None, + centers: list[list[float]] | None = None, + style: str = "mark", + extension: float = 3.0, + layer: str | None = None, +) -> dict: + """Centre marks or centre lines for circular features. + + With `handles`, the circles' real centre and radius are read back out of + the drawing through `entity_get` - the caller is never asked to restate a + radius it could get wrong. With `centers`, each row is `[x, y]` or + `[x, y, radius]`; `style="lines"` needs the radius, because the overrun is + measured from the outline. + """ + from engineering.mech.annotate import draw_annotation_prims + + if not handles and not centers: + raise ValueError("centre_marks needs either handles or centers; both are empty.") + if handles and centers: + raise ValueError("centre_marks takes handles or centers, not both.") + + sources: list[dict] = [] + if handles: + for handle in handles: + info = await backend.entity_get(handle) + if info.type not in _CIRCULAR_TYPES: + raise ValueError( + f"handle {handle} is a {info.type}; a centre mark needs a circular " + f"feature ({', '.join(_CIRCULAR_TYPES)})." + ) + radius = info.properties.get("radius") + if radius is None: + raise ValueError( + f"handle {handle} lies in a plane tilted out of WCS XY, so it has no " + "radius in this frame (capability 'ocs_tilted_plane'); its centre mark " + "cannot be drawn in xy." + ) + center = info.properties["center"] + sources.append( + { + "handle": info.handle, + "center": [float(center[0]), float(center[1])], + "radius": float(radius), + } + ) + else: + for index, row in enumerate(centers or []): + if len(row) < 2: + raise ValueError(f"centers[{index}] must be [x, y] or [x, y, radius].") + radius = float(row[2]) if len(row) > 2 else None + if style == "lines" and radius is None: + raise ValueError( + f"centers[{index}] has no radius, and style='lines' measures its overrun " + "from the outline; pass [x, y, radius] or use style='mark'." + ) + sources.append( + { + "handle": None, + "center": [float(row[0]), float(row[1])], + "radius": radius, + } + ) + + # Validate every source before the first entity is written. + batches = [ + centre_mark_prims( + (source["center"][0], source["center"][1]), + source["radius"] if source["radius"] is not None else 0.0, + style=style, + extension=extension, + ) + for source in sources + ] + handles_out: list[str] = [] + for prims in batches: + drawn = await draw_annotation_prims(backend, prims, layer=layer) + handles_out.extend(drawn["handles"]) + return { + "ok": True, + "handles": handles_out, + "count": len(handles_out), + "style": style, + "sources": sources, + } + + +async def draw_section_line( + backend: AutoCADBackend, + p1: tuple[float, float], + p2: tuple[float, float], + *, + label: str = "A", + direction: tuple[float, float] = (0.0, -1.0), + style: str = "full", + height: float = 5.0, + layer: str | None = None, +) -> dict: + """Draw the cutting-plane line and hand back the plane the view engine consumes.""" + from engineering.mech.annotate import draw_annotation_prims + + plane = section_plane(p1, p2, label=label, direction=direction, style=style) + prims = section_line_prims( + plane["p1"], + plane["p2"], + label=plane["label"], + direction=plane["direction"], + height=height, + ) + result = await draw_annotation_prims(backend, prims, layer=layer) + result["plane"] = plane + result["label"] = plane["label"] + return result + + +async def draw_material_hatch( + backend: AutoCADBackend, + *, + boundary: list[list[float]] | None = None, + handles: list[str] | None = None, + material: str = "steel", + scale: float = 1.0, + angle: float | None = None, + layer: str | None = None, +) -> dict: + """Hatch a closed region with the ISO 128-50 pattern for a material. + + `hatch_for` owns the map and its refusal, so an unknown material name is + rejected here before anything is drawn. `scale` multiplies the material's + own pattern scale; `angle` replaces the material's own angle when given. + """ + from engineering.mech.annotate import ensure_annotation_layers + from engineering.mech.primitives import ROLE_LAYER as _ROLE_LAYER + from engineering.mech.standards.materials import hatch_for + + spec = hatch_for(material) # refuses an unknown material, naming the list + factor = float(scale) + if not math.isfinite(factor) or factor <= 0.0: + raise ValueError(f"scale must be a positive finite number; got {scale!r}.") + + if boundary is None and not handles: + raise ValueError("hatch_material needs either boundary points or handles; both are empty.") + if boundary is not None and handles: + raise ValueError("hatch_material takes boundary or handles, not both.") + + if boundary is not None: + points = [[float(p[0]), float(p[1])] for p in boundary] + if len(points) < 3: + raise ValueError(f"a hatch boundary needs at least three points; got {len(points)}.") + else: + loop = await backend.boundary_from_entities(list(handles or [])) + if not loop.get("ok"): + raise ValueError(f"hatch_material could not close a loop: {loop.get('error')}") + traced = await backend.entity_get(loop["handle"]) + points = [[float(p[0]), float(p[1])] for p in traced.properties["points"]] + + pattern_scale = float(spec["scale"]) * factor + pattern_angle = float(spec["angle"]) if angle is None else float(angle) + target = layer or _ROLE_LAYER["hatch"] + # MEASURED on AutoCAD 2026: `msp.AddHatch(...)` + `Evaluate()` puts the + # AcDbHatch in model space, and the next statement - `entity.Layer = "HATCH"` + # inside `_apply_entity_attrs` - raises `('Key not found')` on a drawing whose + # layer table is `['0']`, leaving that hatch orphaned on layer 0. ActiveX never + # creates the layer; `EzdxfBackend._apply_attrs` does, so without this the same + # call succeeds headless and half-writes live. `drawing_open` runs no bootstrap, + # which is exactly when a cut face has no HATCH layer to land on. + created = await ensure_annotation_layers(backend, [target]) + info = await backend.entity_create_hatch( + spec["pattern"], + points, + pattern_scale, + pattern_angle, + target, + ) + return { + "ok": True, + "handle": info.handle, + "material": material, + "pattern": spec["pattern"], + "scale": pattern_scale, + "angle": pattern_angle, + "points": len(points), + "layer": target, + "layers_created": list(created), + } diff --git a/server.py b/server.py index 85d820b..a9900df 100644 --- a/server.py +++ b/server.py @@ -8124,7 +8124,7 @@ async def drawing_properties_set( # --------------------------------------------------------------------------- -# ── SECTION 23: Mechanical Annotation (2 tools) ───────────────────────────── +# ── SECTION 23: Mechanical Annotation (5 tools) ───────────────────────────── # --------------------------------------------------------------------------- # ISO annotation symbols composed from LINE / ARC / CIRCLE / LWPOLYLINE / TEXT, # so the same symbol lands on COM and ezdxf. The standards tables live in @@ -8304,6 +8304,166 @@ async def weld_symbol( ) +@cad_tool( + summary="Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Centre Marks (ISO 128-23)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def centre_marks( + handles: Annotated[ + list[str] | None, + "Handles of CIRCLEs/ARCs; their real centre and radius are read from the drawing.", + ] = None, + centers: Annotated[ + list[list[float]] | None, + "Explicit centres as [x, y] or [x, y, radius]; style='lines' needs the radius.", + ] = None, + style: Annotated[ + str, + Field( + default="mark", + description="mark (short cross at the centre) | lines (full centre lines).", + ), + ] = "mark", + extension: Annotated[ + float, + Field( + default=3.0, + gt=0, + description="Arm length (mark) or overrun past the outline (lines), mm.", + ), + ] = 3.0, + layer: Annotated[str | None, "Override the CENTER role layer."] = None, + ctx: Context = None, +) -> dict: + """Centre marks on the CENTER layer, whose linetype carries ISO 128-23's dash pattern. + + Pass `handles` and the radii are read back with `entity_get` rather than + restated — a radius from memory is the classic silent wrong number. + Refusals, all before the first entity is written: neither `handles` nor + `centers` given (or both), a handle that is not a CIRCLE or ARC, a circle + in a plane tilted out of WCS XY (capability `ocs_tilted_plane`, so it has + no radius in this frame), a `style` outside mark/lines, a non-positive + `extension`, and `style='lines'` on a centre given without a radius. + """ + from engineering.mech.marks import draw_centre_marks + + await ctx.debug(f"Centre marks style={style}") + return await draw_centre_marks( + _backend(ctx), + handles=handles, + centers=centers, + style=style, + extension=extension, + layer=layer, + ) + + +@cad_tool( + summary="Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Section Line (ISO 128-40)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def section_line( + x1: Annotated[float, "Cutting plane start X (WCS)."], + y1: Annotated[float, "Cutting plane start Y (WCS)."], + x2: Annotated[float, "Cutting plane end X (WCS)."], + y2: Annotated[float, "Cutting plane end Y (WCS)."], + label: Annotated[str, "Capital letter drawn at both ends, e.g. 'A'."] = "A", + direction: Annotated[ + list[float] | None, + "Viewing direction as [dx, dy]; must not be parallel to the cutting plane.", + ] = None, + style: Annotated[ + str, "full | half | offset | revolved — carried into the returned plane." + ] = "full", + height: Annotated[float, Field(default=5.0, gt=0, description="Label height (mm).")] = 5.0, + layer: Annotated[str | None, "Override the role layers (GEOMETRY/CENTER/TEXT)."] = None, + ctx: Context = None, +) -> dict: + """Draw the cutting-plane line and hand back the plane a section view consumes. + + Wide segments at the ends on GEOMETRY, the thin long-dash-dot middle on + CENTER, an arrow at each end whose head sits on the line end and points the + way the section is viewed, and the same letter beyond both arrows. + `payload["plane"]` is exactly `{p1, p2, label, direction, style}` — feed it + to the section view so the label on the plan and the label on the view + cannot disagree. Refusals, before any entity is written: `p1 == p2`, a zero + or parallel viewing direction, an empty `label`, a `style` outside + full/half/offset/revolved, and a non-positive `height`. + """ + from engineering.mech.marks import draw_section_line + + await ctx.debug(f"Section line {label} ({x1},{y1})-({x2},{y2})") + return await draw_section_line( + _backend(ctx), + (x1, y1), + (x2, y2), + label=label, + direction=tuple(direction) if direction else (0.0, -1.0), + style=style, + height=height, + layer=layer, + ) + + +@cad_tool( + summary="Hatch a region with the ISO 128-50 section pattern for a named material.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Material Hatch (ISO 128-50)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def hatch_material( + material: Annotated[str, "Material name; an unknown one is refused with the list."] = "steel", + boundary: Annotated[ + list[list[float]] | None, "Closed boundary as [[x, y], ...] — at least three points." + ] = None, + handles: Annotated[ + list[str] | None, "Entities to chain into one closed loop instead of a boundary." + ] = None, + scale: Annotated[ + float, + Field(default=1.0, gt=0, description="Multiplies the material's own pattern scale."), + ] = 1.0, + angle: Annotated[ + float | None, "Replaces the material's own pattern angle (degrees) when given." + ] = None, + layer: Annotated[str | None, "Override the HATCH role layer."] = None, + ctx: Context = None, +) -> dict: + """Section-hatch a cut face with the pattern ISO 128-50 gives its material. + + Refusals: an unknown material (named, with the full list, before anything + is drawn), neither `boundary` nor `handles` (or both), a boundary with + fewer than three points, and a non-positive `scale`. With `handles` the + loop is chained by `boundary_from_entities`, which the COM engine refuses + (capability `boundary_trace`) — pass `boundary` on a live seat, or run + headlessly. The HATCH layer is created first when the drawing lacks it: + ActiveX refuses an entity on an absent layer and would otherwise leave the + hatch orphaned on layer 0. + """ + from engineering.mech.marks import draw_material_hatch + + await ctx.debug(f"Material hatch {material}") + return await draw_material_hatch( + _backend(ctx), + boundary=boundary, + handles=handles, + material=material, + scale=scale, + angle=angle, + layer=layer, + ) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_mech_marks.py b/tests/test_mech_marks.py new file mode 100644 index 0000000..63625cc --- /dev/null +++ b/tests/test_mech_marks.py @@ -0,0 +1,336 @@ +"""Centre marks (ISO 128-23), the cutting-plane line (ISO 128-40) and material hatch. + +Pure geometry asserted as emitted primitive tuples; the async layer against a +real ezdxf document through the `backend` fixture. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.marks import ( + CENTRE_STYLES, + centre_mark_prims, +) +from engineering.mech.primitives import Line + +pytestmark = pytest.mark.asyncio + +EPS = 1e-9 + + +def _flat(points): + """Flatten a point sequence: pytest.approx refuses nested structures.""" + return tuple(float(value) for point in points for value in point) + + +async def test_a_centre_mark_is_a_small_cross_on_the_centre_role(): + prims = centre_mark_prims((10.0, 20.0), 5.0, style="mark", extension=3.0) + assert len(prims) == 2 + horizontal, vertical = prims + assert isinstance(horizontal, Line) and isinstance(vertical, Line) + assert horizontal.p1 == pytest.approx((7.0, 20.0), abs=EPS) + assert horizontal.p2 == pytest.approx((13.0, 20.0), abs=EPS) + assert vertical.p1 == pytest.approx((10.0, 17.0), abs=EPS) + assert vertical.p2 == pytest.approx((10.0, 23.0), abs=EPS) + assert horizontal.role == "center" and vertical.role == "center" + + +async def test_centre_lines_cross_the_circle_and_overrun_it_by_the_extension(): + prims = centre_mark_prims((10.0, 20.0), 5.0, style="lines", extension=3.0) + horizontal, vertical = prims + assert horizontal.p1 == pytest.approx((2.0, 20.0), abs=EPS) + assert horizontal.p2 == pytest.approx((18.0, 20.0), abs=EPS) + assert vertical.p1 == pytest.approx((10.0, 12.0), abs=EPS) + assert vertical.p2 == pytest.approx((10.0, 28.0), abs=EPS) + assert horizontal.role == "center" and vertical.role == "center" + + +async def test_the_style_enum_is_closed_and_a_bad_radius_is_refused(): + assert CENTRE_STYLES == ("mark", "lines") + with pytest.raises(ValueError) as excinfo: + centre_mark_prims((0.0, 0.0), 5.0, style="cross") + assert "mark, lines" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + centre_mark_prims((0.0, 0.0), 0.0, style="lines") + assert "radius" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + centre_mark_prims((0.0, 0.0), 5.0, extension=0.0) + assert "extension" in str(excinfo.value) + + +# ── ISO 128-40 cutting-plane line ─────────────────────────────────────────── + +from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests + SECTION_STYLES, + section_line_prims, + section_plane, +) +from engineering.mech.primitives import Poly, Text # noqa: E402 + + +async def test_the_cutting_plane_line_is_thick_at_the_ends_and_thin_between(): + prims = section_line_prims( + (0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0 + ) + head_end, tail_end, middle = prims[0], prims[1], prims[2] + assert head_end.p1 == pytest.approx((0.0, 0.0), abs=EPS) + assert head_end.p2 == pytest.approx((10.0, 0.0), abs=EPS) + assert head_end.role == "visible" + assert tail_end.p1 == pytest.approx((90.0, 0.0), abs=EPS) + assert tail_end.p2 == pytest.approx((100.0, 0.0), abs=EPS) + assert tail_end.role == "visible" + assert middle.p1 == pytest.approx((10.0, 0.0), abs=EPS) + assert middle.p2 == pytest.approx((90.0, 0.0), abs=EPS) + assert middle.role == "center" + + +async def test_a_short_cutting_plane_is_one_wide_segment_with_no_thin_middle(): + prims = section_line_prims((0.0, 0.0), (15.0, 0.0), height=5.0) + assert prims[0].p1 == pytest.approx((0.0, 0.0), abs=EPS) + assert prims[0].p2 == pytest.approx((15.0, 0.0), abs=EPS) + assert prims[0].role == "visible" + assert not [p for p in prims if isinstance(p, Line) and p.role == "center"] + + +async def test_each_end_gets_an_arrow_pointing_the_viewing_direction_and_the_same_letter(): + prims = section_line_prims( + (0.0, 0.0), (100.0, 0.0), label="B", direction=(0.0, -1.0), height=5.0 + ) + shafts = [p for p in prims if isinstance(p, Line) and p.p1[0] == p.p2[0]] + assert len(shafts) == 2 + # The shaft comes from outside and the head's tip sits on the line end. + assert shafts[0].p1 == pytest.approx((0.0, 10.0), abs=EPS) + assert shafts[0].p2 == pytest.approx((0.0, 0.0), abs=EPS) + assert shafts[1].p1 == pytest.approx((100.0, 10.0), abs=EPS) + assert shafts[1].p2 == pytest.approx((100.0, 0.0), abs=EPS) + heads = [p for p in prims if isinstance(p, Poly)] + assert len(heads) == 2 and all(h.closed for h in heads) + assert _flat(heads[0].points) == pytest.approx((0.0, 0.0, 1.4, 4.0, -1.4, 4.0), abs=EPS) + labels = [p for p in prims if isinstance(p, Text)] + assert [t.text for t in labels] == ["B", "B"] + assert labels[0].at == pytest.approx((0.0, 13.5), abs=EPS) + assert labels[1].at == pytest.approx((100.0, 13.5), abs=EPS) + assert all(t.height == 5.0 and t.role == "text" for t in labels) + + +async def test_a_viewing_direction_parallel_to_the_plane_is_refused(): + with pytest.raises(ValueError) as excinfo: + section_line_prims((0.0, 0.0), (100.0, 0.0), direction=(1.0, 0.0)) + assert "perpendicular" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + section_line_prims((0.0, 0.0), (0.0, 0.0), direction=(0.0, -1.0)) + assert "distinct" in str(excinfo.value) + + +async def test_section_plane_is_exactly_the_dict_the_view_engine_consumes(): + plane = section_plane((0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -2.0)) + assert plane == { + "p1": (0.0, 0.0), + "p2": (100.0, 0.0), + "label": "A", + "direction": (0.0, -1.0), + "style": "full", + } + assert list(plane) == ["p1", "p2", "label", "direction", "style"] + assert SECTION_STYLES == ("full", "half", "offset", "revolved") + with pytest.raises(ValueError) as excinfo: + section_plane((0.0, 0.0), (10.0, 0.0), style="broken") + assert "full, half, offset, revolved" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + section_plane((0.0, 0.0), (10.0, 0.0), label="") + assert "label" in str(excinfo.value) + + +# ── the async layer ───────────────────────────────────────────────────────── + +from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests + draw_centre_marks, + draw_material_hatch, + draw_section_line, +) +from engineering.mech.primitives import ROLE_LAYER # noqa: E402 +from engineering.mech.standards.materials import MATERIALS, hatch_for # noqa: E402 + + +async def test_centre_marks_read_the_circles_real_radius_from_the_drawing(backend): + small = await backend.entity_create_circle(0.0, 0.0, 4.0, "GEOMETRY") + large = await backend.entity_create_circle(50.0, 0.0, 12.0, "GEOMETRY") + result = await draw_centre_marks( + backend, handles=[small.handle, large.handle], style="lines", extension=2.0 + ) + assert result["ok"] is True and result["count"] == 4 + assert result["sources"] == [ + {"handle": small.handle, "center": [0.0, 0.0], "radius": 4.0}, + {"handle": large.handle, "center": [50.0, 0.0], "radius": 12.0}, + ] + first = await backend.entity_get(result["handles"][0]) + assert first.layer == ROLE_LAYER["center"] + assert first.properties["start"] == pytest.approx([-6.0, 0.0], abs=EPS) + assert first.properties["end"] == pytest.approx([6.0, 0.0], abs=EPS) + third = await backend.entity_get(result["handles"][2]) + assert third.properties["start"] == pytest.approx([36.0, 0.0], abs=EPS) + assert third.properties["end"] == pytest.approx([64.0, 0.0], abs=EPS) + + +async def test_centre_marks_refuse_a_handle_that_is_not_a_circular_feature(backend): + line = await backend.entity_create_line(0.0, 0.0, 10.0, 0.0, 0.0, 0.0, "GEOMETRY") + before = (await backend.analysis_stats())["total_entities"] + with pytest.raises(ValueError) as excinfo: + await draw_centre_marks(backend, handles=[line.handle]) + assert line.handle in str(excinfo.value) and "LINE" in str(excinfo.value) + assert (await backend.analysis_stats())["total_entities"] == before + + +async def test_explicit_centres_are_accepted_and_lines_need_a_radius(backend): + result = await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="mark", extension=3.0) + assert result["count"] == 2 and result["sources"] == [ + {"handle": None, "center": [5.0, 5.0], "radius": None} + ] + with pytest.raises(ValueError) as excinfo: + await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="lines") + assert "centers[0]" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + await draw_centre_marks(backend) + assert "handles" in str(excinfo.value) and "centers" in str(excinfo.value) + + +async def test_draw_section_line_returns_the_plane_dict_the_view_engine_consumes(backend): + result = await draw_section_line( + backend, (0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0 + ) + assert result["ok"] is True + assert result["plane"] == { + "p1": (0.0, 0.0), + "p2": (100.0, 0.0), + "label": "A", + "direction": (0.0, -1.0), + "style": "full", + } + assert list(result["plane"]) == ["p1", "p2", "label", "direction", "style"] + layers = set() + for handle in result["handles"]: + layers.add((await backend.entity_get(handle)).layer) + assert layers == {ROLE_LAYER["visible"], ROLE_LAYER["center"], ROLE_LAYER["text"]} + + +async def test_material_hatch_uses_the_iso_128_50_map_and_scales_on_top_of_it(backend): + steel = hatch_for("steel") + result = await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [40.0, 0.0], [40.0, 20.0], [0.0, 20.0]], + material="steel", + scale=2.0, + ) + assert result["ok"] is True and result["material"] == "steel" + assert result["pattern"] == steel["pattern"] + assert result["scale"] == pytest.approx(steel["scale"] * 2.0, abs=EPS) + assert result["angle"] == pytest.approx(steel["angle"], abs=EPS) + hatch = backend._doc.entitydb.get(result["handle"]) + assert hatch.dxftype() == "HATCH" + assert hatch.dxf.pattern_name == steel["pattern"] + assert hatch.dxf.pattern_scale == pytest.approx(steel["scale"] * 2.0, abs=EPS) + assert hatch.dxf.pattern_angle == pytest.approx(steel["angle"], abs=EPS) + assert (await backend.entity_get(result["handle"])).layer == ROLE_LAYER["hatch"] + # An explicit angle overrides the material's own. + turned = await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], + material="steel", + angle=90.0, + ) + assert turned["angle"] == pytest.approx(90.0, abs=EPS) + + +async def test_an_unknown_material_is_refused_with_the_list_before_any_write(backend): + assert "steel" in MATERIALS + before = (await backend.analysis_stats())["total_entities"] + with pytest.raises(ValueError): + await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], + material="unobtainium", + ) + assert (await backend.analysis_stats())["total_entities"] == before + + +async def test_a_boundary_that_is_not_a_closed_polygon_is_refused(backend): + with pytest.raises(ValueError) as excinfo: + await draw_material_hatch(backend, boundary=[[0.0, 0.0], [10.0, 0.0]], material="steel") + assert "at least three" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + await draw_material_hatch(backend, material="steel") + assert "boundary" in str(excinfo.value) and "handles" in str(excinfo.value) + + +# ── the HATCH layer must exist before the hatch is created (live) ──────────── + +import types # noqa: E402 + + +class _StrictLayerHatchBackend: + """A backend that refuses a hatch on a layer the drawing does not have. + + MEASURED on AutoCAD 2026 (probe: GetActiveObject, `Documents.Add()`, a + scratch document whose layer table is `['0']`, `doc.Close(False)` in a + `finally:`): `msp.AddHatch(0, "ANSI31", False)` + `AppendOuterLoop` + + `Evaluate()` leaves the AcDbHatch in model space, and the very next + statement `hatch.Layer = "HATCH"` raises + `com_error (-2147352567, ..., 'Key not found', ..., -2145386476)`. Model + space then still reports `[('AcDbHatch', '0')]` and the layer table is + still `['0']` - the refusal leaves an orphan hatch behind. After + `doc.Layers.Add("HATCH")` the same assignment succeeds. `ComBackend` + reaches that assignment through `_apply_entity_attrs`, which is the one + line `entity.Layer = layer`, so this is the hatch spelling of the failure + `ensure_annotation_layers` already fixed for the annotation symbols. + + The fake models that measured refusal and nothing else: every member below + exists on the real `AutoCADBackend` with this signature. + """ + + def __init__(self, layers=("0",)): + self.layers = {name.lower(): name for name in layers} + self.calls: list[tuple] = [] + + async def layer_list(self): + self.calls.append(("layer_list",)) + return [types.SimpleNamespace(name=name) for name in self.layers.values()] + + async def layer_create(self, name, color=7, linetype="Continuous", lineweight=-3): + self.calls.append(("layer_create", name, color, linetype, lineweight)) + self.layers[name.lower()] = name + return types.SimpleNamespace(name=name) + + async def entity_create_hatch( + self, pattern, boundary_points, scale=1.0, angle=0.0, layer=None, color=None + ): + if layer is not None and layer.lower() not in self.layers: + raise RuntimeError(f"AutoCAD COM error (-0x7ffdfff7) ('Key not found'): {layer}") + self.calls.append(("HATCH", layer, pattern)) + return types.SimpleNamespace(handle="2F") + + +async def test_the_hatch_layer_is_created_before_the_hatch_is(): + backend = _StrictLayerHatchBackend() + result = await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [40.0, 0.0], [40.0, 20.0], [0.0, 20.0]], + material="steel", + ) + assert result["ok"] is True and result["handle"] == "2F" + assert result["layers_created"] == [ROLE_LAYER["hatch"]] + # The layer is created strictly before the hatch entity. + kinds = [call[0] for call in backend.calls] + assert kinds.index("layer_create") < kinds.index("HATCH") + # ENGINEERING_LAYERS' own definition, not a bare default. + create = next(c for c in backend.calls if c[0] == "layer_create") + assert create[1] == ROLE_LAYER["hatch"] + + +async def test_a_drawing_that_already_has_the_hatch_layer_is_not_rewritten(): + backend = _StrictLayerHatchBackend(layers=("0", "hatch")) + result = await draw_material_hatch( + backend, boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], material="steel" + ) + assert result["layers_created"] == [] + assert not [c for c in backend.calls if c[0] == "layer_create"] diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index d65fc77..2fc2533 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (206 tools, 23 groups). + """Frozen snapshot of the surface (209 tools, 23 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and @@ -531,9 +531,10 @@ async def test_tool_group_sizes_are_unchanged(): since the snapshot was taken. `engineering` moved 10 -> 12 when v1.6's SECTION 23 opened with - `surface_texture` / `weld_symbol`; both are tagged `engineering` and `mech`, - and `mech` is not in `_GROUP_TAG_PRIORITY`, so they file under - `engineering` rather than opening a group of their own. + `surface_texture` / `weld_symbol`, then 12 -> 15 when `centre_marks`, + `section_line` and `hatch_material` completed it; all five are tagged + `engineering` and `mech`, and `mech` is not in `_GROUP_TAG_PRIORITY`, so + they file under `engineering` rather than opening a group of their own. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -543,7 +544,7 @@ async def test_tool_group_sizes_are_unchanged(): "corner_ops": 4, "dimensions": 5, "drawing": 11, - "engineering": 12, + "engineering": 15, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, @@ -561,4 +562,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 206 + assert sum(sizes.values()) == 209 From 34079ad0b48e906f27e8b722d39ffa21c842892a Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 20:12:11 +0300 Subject: [PATCH 4/4] fix(task-11): hatch the cut face, not its chord, and take the scaffolding with it draw_material_hatch's handles branch read only the traced loop's `points` and dropped the parallel `bulges`, so a cut face bounded by an arc was hatched as the chord polygon - exactly the failure README.md and the comment at backends/ezdxf_backend.py:1172-1176 name. MEASURED headless on a 40x10 box closed by a 180 degree r=20 arc: boundary_from_entities -> area 1028.318531, draw_material_hatch -> {'ok': True, 'points': 4} and entity_measure(hatch) -> {'area': 400.0, 'exact': True}. 628.3 mm2, 61% of the face, silently unhatched, and the payload stated no accuracy. entity_create_hatch takes a vertex list on both engines, so an arc edge has to reach it as vertices; the bug was dropping the curve, not the fact of vertices. Each bulged segment is now expanded onto its real arc by _flatten_bulge, spaced so no chord departs from it by more than the declared HATCH_FLATTEN_SAGITTA (0.01). Same repro after: points 53, area 1027.905195 against the exact 1028.318531 - an error of 0.413 (0.040%), and the payload says so rather than claiming exactness: `accuracy` "flatten_tolerance" or "exact", `flatten_tolerance`, `curved_edges`, `area` (the polygon actually written) and `boundary_area` (the loop's own exact area) so the two can be compared instead of trusted. `accuracy` is decided by comparing those two numbers, so a backend that reports no `bulges` at all is still reported approximate rather than assumed exact. A straight-sided face reads `exact` / 400.0 as before. The handles branch also had no area guard - the "at least three points" refusal only ever covered `boundary` - so a LINE plus a 180 degree ARC traced to a two-vertex loop and wrote a HATCH of area 0.0 with ok: True. That case now hatches the real semicircle (37 points, 156.880337 of 157.079633), and a loop that genuinely encloses nothing (three collinear segments, boundary_from_entities area 0.0) is refused by name before anything is written: "the hatch boundary encloses no area (measured 0)". Second: boundary_from_entities does not merely compute a loop, it *writes* one. The traced LWPOLYLINE was left in the drawing on layer 0 and named nowhere in the result, so the caller could neither find nor delete it, and drawing_critique / drawing_finalize scored it against the drawing under the layer-discipline rule. MEASURED headless on a 40x10 rectangle of four LINEs: 4 entities before, 6 after, by_type {'LINE': 4, 'LWPOLYLINE': 1, 'HATCH': 1}, the polyline on layer '0'. After: 5 entities, {'LINE': 4, 'HATCH': 1}, and the result carries traced {'handle': '34', 'removed': True} - the boolean is reported rather than assumed, so a delete that failed is visible. The delete runs in a `finally:`, so the refusal paths above do not leave scaffolding either (3 entities before and after). Third: the module SOURCE block asserted the cutting-plane line's thin middle was "the `center` role (CENTER, 0.25 mm)" and that the pair gave "the ISO appearance" at 2:1. engineering/layers.py:19 defines CENTER at 0.18. MEASURED headless after draw_section_line on a fresh EzdxfBackend: layer_list reports GEOMETRY lineweight 50.0 and CENTER 18.0, i.e. 0.50/0.18 = 2.78:1. A fabricated number in a standards SOURCE block is the one thing these blocks exist to prevent, so the block now states the measured pair, names the ratio as 2.78:1, and does *not* quote the standard's own wide:narrow ratio, which this module cannot transcribe. The wrong figure in the previous commit message (64a57d7) stands uncorrected in history; this paragraph is the correction. Tests (all four fail against the reviewed code, verified by re-running them with _flatten_bulge and _discard_traced_loop stubbed back to the old behaviour: 3 failed, and the SOURCE-block assertions checked against HEAD:engineering/mech/marks.py -> [False, False, False]): - test_a_bulged_segment_is_expanded_onto_its_real_arc - every emitted vertex on the circle, every chord sagitta inside the tolerance - test_an_arc_bounded_cut_face_is_hatched_on_the_arc_not_its_chord - and entity_measure agrees with the payload - test_a_straight_sided_face_is_still_reported_exact - test_the_traced_outline_is_removed_and_reported_not_left_on_layer_0 - test_a_loop_that_encloses_no_area_is_refused_and_leaves_nothing_behind - test_the_polygon_area_helper_is_orientation_blind - test_the_section_line_source_block_quotes_the_layer_set_it_draws_on - reads ENGINEERING_LAYERS, so the sentence cannot drift again - test_the_cutting_plane_line_really_lands_on_those_two_lineweights No new ActiveX call was written, and nothing changed here can reach a live seat: boundary_from_entities is capability-refused on COM (backends/com_backend.py:1488, boundary_trace FeatureCapability(False, "no_activex_member_command_only")), so the handles branch is headless-only; the `boundary` branch is unchanged but for the added payload keys. The live seat could not be reached to re-measure it anyway - GetActiveObject("AutoCAD.Application") blocked without returning on three attempts over ~370 s while acad.exe was running (sibling contention), so the live engine was unavailable rather than absent. server.py's hatch_material docstring now states the accuracy keys and the traced-outline deletion; the tool summary is unchanged, and scripts/generate_tool_inventory.py reproduces docs/tool-inventory.json byte-for-byte (209 tools), so no registration moved. tests/test_mech_marks.py: 25 passed (17 -> 25). Gates: ruff check clean over the tree, ruff format clean, test_tool_registry, test_tool_inventory, test_capability_contract, test_discovery_aliases, test_boundary_trace, test_measure and test_release_consistency all pass. Full suite: 3351 passed, 24 skipped, 1 xfailed; the same 6 pre-existing failures (test_benchmark_v4, test_capabilities, test_layouts_solids, test_page_setup, test_sprint2_fixes, test_viewport_ops) verified identical with this change stashed, and test_release_consistency.py::test_document_numbers_match_the_tree deselected as Global Constraints instructs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/marks.py | 189 ++++++++++++++++++++++++++++++++++++-- server.py | 12 ++- tests/test_mech_marks.py | 159 ++++++++++++++++++++++++++++++++ 3 files changed, 352 insertions(+), 8 deletions(-) diff --git a/engineering/mech/marks.py b/engineering/mech/marks.py index a4eff9a..8179995 100644 --- a/engineering/mech/marks.py +++ b/engineering/mech/marks.py @@ -18,9 +18,23 @@ principles of presentation - Basic conventions for cuts and sections": a long-dash-dotted line that is *wide* at its ends and at every change of direction, an arrow at each end pointing in the direction of viewing, and the same capital letter at both ends. Line width is a layer property here, so the -wide ends are emitted on the `visible` role (GEOMETRY, 0.50 mm) and the thin -middle on the `center` role (CENTER, 0.25 mm) - the ISO appearance, expressed -in the role vocabulary this repository already has. +wide ends are emitted on the `visible` role and the thin middle on the +`center` role - the wide/narrow contrast, expressed in the role vocabulary +this repository already has. + +The two widths are not numbers this module picks: they are whatever +`engineering.layers.ENGINEERING_LAYERS` gives GEOMETRY and CENTER. MEASURED +on a drawing `drawing_new` bootstrapped: + + GEOMETRY is 0.50 mm, CENTER is 0.18 mm, a ratio of 2.78:1. + +That measured pair is what is stated here - not the standard's own +wide:narrow ratio, which this module does not transcribe. +A ratio quoted from memory beside a measured one is exactly the invented +number the SOURCE blocks above refuse to ship, and an earlier revision of +this block did ship one (it said CENTER was 0.25 mm and the pair 2:1; both +were wrong). `tests/test_mech_marks.py` now pins these three figures to +`ENGINEERING_LAYERS`, so the sentence cannot drift from the layer set again. DECLARED, not transcribed: ISO 128-40 says the ends are thickened and that arrows are placed there; it does not dimension them. The end-segment length, @@ -44,6 +58,7 @@ if TYPE_CHECKING: # pragma: no cover - typing only __all__ = [ "CENTRE_STYLES", + "HATCH_FLATTEN_SAGITTA", "SECTION_STYLES", "centre_mark_prims", "draw_centre_marks", @@ -65,6 +80,17 @@ ARROW_SHAFT_FACTOR = 2.0 # arrow shaft length, perpendicular to the plane ARROW_HEAD_FACTOR = 0.8 # arrowhead length (half-width is 0.35 of it, as elsewhere) LABEL_OFFSET_FACTOR = 0.7 # gap between the arrow tail and the label +# DECLARED, not transcribed: the largest gap (drawing units) left between a +# curved boundary edge and the chords that stand in for it. +# `entity_create_hatch` takes a vertex list on both engines, so an arc edge +# has to reach it as vertices; what must not happen - and did - is dropping +# the curve and hatching the chord. MEASURED on the r=20 semicircular cut +# face in `tests/test_mech_marks.py`: 0.01 spaces that arc into 50 chords and +# hatches 1027.905195 of its real 1028.318531, an error of 0.413 (0.040%), +# against the 628.3 (61%) an earlier revision silently lost. The payload says +# `accuracy: "flatten_tolerance"` rather than claiming the area is exact. +HATCH_FLATTEN_SAGITTA = 0.01 + def centre_mark_prims( center: tuple[float, float], @@ -335,6 +361,89 @@ async def draw_section_line( return result +def _polygon_area(points: list[tuple[float, float]]) -> float: + """The shoelace area of a closed straight-edged polygon. + + Legitimate here and nowhere else in this module: it is applied to the + vertex list actually written into the HATCH, every edge of which really is + a straight line, so it measures what was drawn rather than approximating + what was meant. The curved input is handled by `_flatten_bulge` *before* + this sees it, and the residual is reported, never swallowed. + """ + total = 0.0 + count = len(points) + for index in range(count): + x1, y1 = points[index] + x2, y2 = points[(index + 1) % count] + total += x1 * y2 - x2 * y1 + return abs(total) / 2.0 + + +def _flatten_bulge( + p1: tuple[float, float], + p2: tuple[float, float], + bulge: float, + tolerance: float = HATCH_FLATTEN_SAGITTA, +) -> list[tuple[float, float]]: + """The interior vertices standing in for one bulged polyline segment. + + DXF stores a circular arc between two LWPOLYLINE vertices as a *bulge*: + tan(theta/4) of the arc's included angle, positive counter-clockwise. The + points returned lie strictly between `p1` and `p2`, spaced so no chord + departs from the arc by more than `tolerance`; an empty list means the + segment is already straight. + """ + b = float(bulge) + if not math.isfinite(b) or abs(b) <= 1e-12: + return [] + x1, y1 = float(p1[0]), float(p1[1]) + x2, y2 = float(p2[0]), float(p2[1]) + chord = math.hypot(x2 - x1, y2 - y1) + if chord <= 1e-12: + return [] + theta = 4.0 * math.atan(b) + half = theta / 2.0 + radius = (chord / 2.0) / abs(math.sin(half)) + # Centre: off the chord's midpoint along its left normal, signed by the + # sweep, so a negative bulge puts it on the other side without a branch. + ux, uy = (x2 - x1) / chord, (y2 - y1) / chord + offset = -(chord / 2.0) / math.tan(half) + cx = (x1 + x2) / 2.0 + (-uy) * offset + cy = (y1 + y2) / 2.0 + ux * offset + tol = min(float(tolerance), radius) + step = 2.0 * math.acos(max(-1.0, min(1.0, 1.0 - tol / radius))) + count = max(2, math.ceil(abs(theta) / step)) if step > 1e-12 else 2 + start = math.atan2(y1 - cy, x1 - cx) + return [ + ( + cx + radius * math.cos(start + theta * index / count), + cy + radius * math.sin(start + theta * index / count), + ) + for index in range(1, count) + ] + + +async def _discard_traced_loop(backend: AutoCADBackend, handle: str | None) -> bool: + """Delete the outline `boundary_from_entities` drew, reporting whether it went. + + `boundary_from_entities` does not merely compute a loop, it *writes* one: + an LWPOLYLINE on the current layer. `hatch_material` asked for a hatch, not + an outline, so leaving it behind drops a stray polyline on layer 0 that the + caller cannot find - and that `drawing_critique` / `drawing_finalize` then + score against the drawing under the layer-discipline rule. The boolean is + put in the result rather than assumed, so a delete that failed is visible. + """ + if not handle: + return False + try: + result = await backend.entity_delete(handle) + except Exception: + return False + if isinstance(result, dict): + return bool(result.get("ok", True)) + return True + + async def draw_material_hatch( backend: AutoCADBackend, *, @@ -350,6 +459,24 @@ async def draw_material_hatch( `hatch_for` owns the map and its refusal, so an unknown material name is rejected here before anything is drawn. `scale` multiplies the material's own pattern scale; `angle` replaces the material's own angle when given. + + With `handles` the loop is chained by `boundary_from_entities`, and both of + that call's side effects are dealt with here rather than left to the + caller. Its traced LWPOLYLINE carries `points` **and** a parallel `bulges` + list; reading only `points` hatches the chord of every arc edge, which on a + semicircular cut face leaves 61% of the face unhatched while reporting + success. Each bulged segment is therefore expanded into chords no further + than `HATCH_FLATTEN_SAGITTA` from the real arc, and the payload states its + own accuracy the way `analysis_measure_entity` does: `accuracy` is + `"exact"` or `"flatten_tolerance"`, `area` is the polygon actually written + and `boundary_area` the exact area of the chained loop, so the two can be + compared instead of trusted. The traced outline itself is deleted - it was + scaffolding, and an orphan polyline on layer 0 is scored against the + drawing - and `traced` reports its handle and whether it went. + + Refused: a loop that encloses no area, and a traced loop that flattens to + fewer than three vertices. Both used to write a HATCH of area 0.0 and + return `ok`. """ from engineering.mech.annotate import ensure_annotation_layers from engineering.mech.primitives import ROLE_LAYER as _ROLE_LAYER @@ -365,16 +492,58 @@ async def draw_material_hatch( if boundary is not None and handles: raise ValueError("hatch_material takes boundary or handles, not both.") + traced_report: dict | None = None + boundary_area: float | None = None + curved_edges = 0 if boundary is not None: - points = [[float(p[0]), float(p[1])] for p in boundary] + points = [(float(p[0]), float(p[1])) for p in boundary] if len(points) < 3: raise ValueError(f"a hatch boundary needs at least three points; got {len(points)}.") else: loop = await backend.boundary_from_entities(list(handles or [])) if not loop.get("ok"): raise ValueError(f"hatch_material could not close a loop: {loop.get('error')}") - traced = await backend.entity_get(loop["handle"]) - points = [[float(p[0]), float(p[1])] for p in traced.properties["points"]] + traced_handle = loop.get("handle") + if loop.get("area") is not None: + boundary_area = float(loop["area"]) + try: + traced = await backend.entity_get(traced_handle) + vertices = [(float(p[0]), float(p[1])) for p in traced.properties["points"]] + bulges = [float(b) for b in (traced.properties.get("bulges") or ())] + finally: + # In `finally:` so a drawing is never left with the scaffolding of + # a call that raised while reading it. + traced_report = { + "handle": traced_handle, + "removed": await _discard_traced_loop(backend, traced_handle), + } + points = [] + for index, vertex in enumerate(vertices): + points.append(vertex) + bulge = bulges[index] if index < len(bulges) else 0.0 + arc = _flatten_bulge(vertex, vertices[(index + 1) % len(vertices)], bulge) + if arc: + curved_edges += 1 + points.extend(arc) + if len(points) < 3: + raise ValueError( + f"the chained loop flattens to {len(points)} vertices, which encloses nothing; " + "a hatch boundary needs at least three." + ) + + area = _polygon_area(points) + if area <= 1e-9: + raise ValueError( + f"the hatch boundary encloses no area (measured {area:g}); a zero-area HATCH " + "fills nothing while reporting success." + ) + # The traced loop knows its own exact area, arcs and all. If the polygon + # written here does not match it, something was straightened - say so by + # name instead of reporting an area that is not the cut face's. + if boundary_area is None or abs(area - boundary_area) <= max(1e-9, 1e-9 * abs(boundary_area)): + accuracy, flatten_tolerance = "exact", None + else: + accuracy, flatten_tolerance = "flatten_tolerance", HATCH_FLATTEN_SAGITTA pattern_scale = float(spec["scale"]) * factor pattern_angle = float(spec["angle"]) if angle is None else float(angle) @@ -389,7 +558,7 @@ async def draw_material_hatch( created = await ensure_annotation_layers(backend, [target]) info = await backend.entity_create_hatch( spec["pattern"], - points, + [[x, y] for x, y in points], pattern_scale, pattern_angle, target, @@ -402,6 +571,12 @@ async def draw_material_hatch( "scale": pattern_scale, "angle": pattern_angle, "points": len(points), + "area": round(area, 6), + "boundary_area": boundary_area, + "accuracy": accuracy, + "flatten_tolerance": flatten_tolerance, + "curved_edges": curved_edges, "layer": target, "layers_created": list(created), + "traced": traced_report, } diff --git a/server.py b/server.py index a9900df..84bfa68 100644 --- a/server.py +++ b/server.py @@ -8445,10 +8445,20 @@ async def hatch_material( is drawn), neither `boundary` nor `handles` (or both), a boundary with fewer than three points, and a non-positive `scale`. With `handles` the loop is chained by `boundary_from_entities`, which the COM engine refuses - (capability `boundary_trace`) — pass `boundary` on a live seat, or run + (capability `boundary_trace`) - pass `boundary` on a live seat, or run headlessly. The HATCH layer is created first when the drawing lacks it: ActiveX refuses an entity on an absent layer and would otherwise leave the hatch orphaned on layer 0. + + The payload states its own accuracy, as `analysis_measure_entity` does: + `area` is the polygon actually hatched, `boundary_area` the exact area of + the chained loop, and `accuracy` is `"exact"` or `"flatten_tolerance"` - + an arc edge reaches `entity_create_hatch` as chords, so a curved cut face + is hatched to within `HATCH_FLATTEN_SAGITTA` of itself rather than being + replaced by its chord polygon. With `handles`, the outline + `boundary_from_entities` draws is scaffolding and is deleted; `traced` + reports its handle and whether the delete succeeded. A loop enclosing no + area is refused instead of writing a HATCH of area 0.0. """ from engineering.mech.marks import draw_material_hatch diff --git a/tests/test_mech_marks.py b/tests/test_mech_marks.py index 63625cc..3c5c01a 100644 --- a/tests/test_mech_marks.py +++ b/tests/test_mech_marks.py @@ -6,6 +6,8 @@ real ezdxf document through the `backend` fixture. from __future__ import annotations +import math + import pytest from engineering.mech.marks import ( @@ -334,3 +336,160 @@ async def test_a_drawing_that_already_has_the_hatch_layer_is_not_rewritten(): ) assert result["layers_created"] == [] assert not [c for c in backend.calls if c[0] == "layer_create"] + + +# ── the cut face is the face, not its chords (regression) ─────────────────── + +from engineering.layers import ENGINEERING_LAYERS # noqa: E402 +from engineering.mech import marks as _marks # noqa: E402 +from engineering.mech.marks import ( # noqa: E402 + HATCH_FLATTEN_SAGITTA, + _flatten_bulge, + _polygon_area, +) + + +async def test_a_bulged_segment_is_expanded_onto_its_real_arc(): + """A bulge of 1 is a 180 degree arc; its chords must sit on that circle.""" + interior = _flatten_bulge((40.0, 10.0), (0.0, 10.0), 1.0) + assert len(interior) >= 40 # a 0.01 sagitta on r=20 needs ~50 chords + for x, y in interior: + assert math.hypot(x - 20.0, y - 10.0) == pytest.approx(20.0, abs=1e-9) + assert y >= 10.0 # a positive bulge sweeps counter-clockwise, i.e. above + # Every chord's sagitta stays inside the declared tolerance. + walk = [(40.0, 10.0), *interior, (0.0, 10.0)] + for (x1, y1), (x2, y2) in zip(walk, walk[1:], strict=False): + chord = math.hypot(x2 - x1, y2 - y1) + sagitta = 20.0 - math.sqrt(max(0.0, 400.0 - (chord / 2.0) ** 2)) + assert sagitta <= HATCH_FLATTEN_SAGITTA + 1e-12 + assert _flatten_bulge((0.0, 0.0), (10.0, 0.0), 0.0) == [] + + +async def _arc_topped_face(backend): + """A 40x10 box closed by a 180 degree arc: 400 straight + 628.3 under the arc.""" + lower = await backend.entity_create_line(0.0, 0.0, 40.0, 0.0, 0.0, 0.0, "GEOMETRY") + right = await backend.entity_create_line(40.0, 0.0, 40.0, 10.0, 0.0, 0.0, "GEOMETRY") + arc = await backend.entity_create_arc(20.0, 10.0, 20.0, 0.0, 180.0, "GEOMETRY") + left = await backend.entity_create_line(0.0, 10.0, 0.0, 0.0, 0.0, 0.0, "GEOMETRY") + return [lower.handle, right.handle, arc.handle, left.handle] + + +async def _rectangle(backend): + handles = [] + for start, end in ( + ((0.0, 0.0), (40.0, 0.0)), + ((40.0, 0.0), (40.0, 10.0)), + ((40.0, 10.0), (0.0, 10.0)), + ((0.0, 10.0), (0.0, 0.0)), + ): + line = await backend.entity_create_line(start[0], start[1], end[0], end[1], 0.0, 0.0) + handles.append(line.handle) + return handles + + +async def test_an_arc_bounded_cut_face_is_hatched_on_the_arc_not_its_chord(backend): + """The failure README.md names: reading `points` drops the parallel `bulges`. + + Reading only `traced.properties["points"]` hatched the chord polygon - + 400.0 of a 1028.3 cut face, 61% of it silently unhatched. The hatch must + now measure the face, and the payload must state its own accuracy. + """ + handles = await _arc_topped_face(backend) + exact = (await backend.boundary_from_entities(handles))["area"] + assert exact == pytest.approx(400.0 + 200.0 * math.pi, abs=1e-6) + + result = await draw_material_hatch(backend, handles=handles, material="steel") + assert result["ok"] is True + assert result["curved_edges"] == 1 + assert result["points"] > 4 # not the four-vertex chord polygon + assert result["boundary_area"] == pytest.approx(exact, abs=1e-6) + # Inside the declared flattening tolerance of the real face, not 61% short. + assert result["area"] == pytest.approx(exact, rel=1e-3) + assert result["accuracy"] == "flatten_tolerance" + assert result["flatten_tolerance"] == HATCH_FLATTEN_SAGITTA + # What the drawing holds agrees with what the payload claims. + measured = await backend.entity_measure(result["handle"]) + assert measured["area"] == pytest.approx(result["area"], abs=1e-6) + + +async def test_a_straight_sided_face_is_still_reported_exact(backend): + handles = await _rectangle(backend) + result = await draw_material_hatch(backend, handles=handles, material="steel") + assert result["accuracy"] == "exact" + assert result["flatten_tolerance"] is None + assert result["curved_edges"] == 0 + assert result["area"] == pytest.approx(400.0, abs=EPS) + assert result["boundary_area"] == pytest.approx(400.0, abs=EPS) + assert result["points"] == 4 + + +async def test_the_traced_outline_is_removed_and_reported_not_left_on_layer_0(backend): + """`boundary_from_entities` *draws* its loop; hatch_material asked for a hatch.""" + handles = await _rectangle(backend) + before = await backend.analysis_stats() + result = await draw_material_hatch(backend, handles=handles, material="steel") + after = await backend.analysis_stats() + # Exactly one new entity: the hatch. No LWPOLYLINE scaffolding survives. + assert after["total_entities"] == before["total_entities"] + 1 + assert after["by_type"].get("LWPOLYLINE", 0) == 0 + assert result["traced"]["removed"] is True + assert result["traced"]["handle"] + live = {entity.dxf.handle for entity in backend._doc.modelspace()} + assert result["traced"]["handle"] not in live + + +async def test_a_loop_that_encloses_no_area_is_refused_and_leaves_nothing_behind(backend): + """Three collinear segments chain into a closed loop of area 0.0. + + The handles branch had no such guard - the 'at least three points' refusal + only ever covered `boundary` - so it wrote a HATCH of area 0.0 and returned + ok. The traced outline must still be cleaned up on the refusal path. + """ + handles = [] + for start, end in ( + ((0.0, 0.0), (10.0, 0.0)), + ((10.0, 0.0), (20.0, 0.0)), + ((20.0, 0.0), (0.0, 0.0)), + ): + line = await backend.entity_create_line(start[0], start[1], end[0], end[1], 0.0, 0.0) + handles.append(line.handle) + before = await backend.analysis_stats() + with pytest.raises(ValueError) as excinfo: + await draw_material_hatch(backend, handles=handles, material="steel") + assert "no area" in str(excinfo.value) + after = await backend.analysis_stats() + assert after["total_entities"] == before["total_entities"] + assert after["by_type"].get("LWPOLYLINE", 0) == 0 + assert after["by_type"].get("HATCH", 0) == 0 + + +async def test_the_polygon_area_helper_is_orientation_blind(): + square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] + assert _polygon_area(square) == pytest.approx(100.0, abs=EPS) + assert _polygon_area(list(reversed(square))) == pytest.approx(100.0, abs=EPS) + + +# ── the SOURCE block may not quote a lineweight the layer set does not have ── + + +async def test_the_section_line_source_block_quotes_the_layer_set_it_draws_on(): + """A SOURCE block asserting 0.25 mm while CENTER is 0.18 mm is a fabricated + number in the one place this repository refuses to fabricate one.""" + widths = {name: weight for name, _c, _lt, weight, _d in ENGINEERING_LAYERS} + wide, narrow = widths["GEOMETRY"], widths["CENTER"] + doc = _marks.__doc__ + assert f"GEOMETRY is {wide:.2f} mm" in doc + assert f"CENTER is {narrow:.2f} mm" in doc + assert f"{wide / narrow:.2f}:1" in doc + + +async def test_the_cutting_plane_line_really_lands_on_those_two_lineweights(backend): + result = await draw_section_line( + backend, (0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0 + ) + widths = {layer.name: layer.lineweight for layer in await backend.layer_list()} + drawn = {(await backend.entity_get(handle)).layer for handle in result["handles"]} + assert {ROLE_LAYER["visible"], ROLE_LAYER["center"]} <= drawn + # lineweight is reported in 1/100 mm; the SOURCE block quotes millimetres. + assert widths[ROLE_LAYER["visible"]] == pytest.approx(50.0, abs=EPS) + assert widths[ROLE_LAYER["center"]] == pytest.approx(18.0, abs=EPS)