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