From 64a57d74a00b10364a978806c3dd47fdb1cf3f0a Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:25:13 +0300 Subject: [PATCH] feat(mech): SECTION 23 completes with centre_marks, section_line, hatch_material centre_marks draws ISO 128-23 centre marks or full centre lines and reads each circle's real centre and radius back out of the drawing through entity_get instead of being told them - a radius restated from memory is the silent wrong number this repository keeps refusing to ship. A handle that is not a CIRCLE or ARC, and a circle in a plane tilted out of WCS XY (which has no radius in this frame), are both refused by handle before anything is written. The dash proportions are not transcribed here: the prims are plain lines on CENTER, whose linetype already carries them. section_line draws the ISO 128-40 cutting-plane line - wide segments at the ends on GEOMETRY, the thin long-dash-dot middle on CENTER, an arrow at each end whose head sits on the line end and points the way the section is viewed, the same letter beyond both arrows - and returns the five-key plane dict {p1, p2, label, direction, style} that mech_view_add(kind= "section") consumes, so the label on the plan and the label on the view are the same string by construction. A viewing direction parallel to the plane is refused rather than drawn. hatch_material hatches through standards.materials.hatch_for, so the ISO 128-50 map and its refusal stay in one place; scale multiplies the material's own pattern scale and angle replaces its angle. An unknown material is refused with the list before any entity is written. DEVIATION - one defect in the plan's own draw_material_hatch, fixed here with two tests that fail against the plan's code. It called entity_create_hatch with ROLE_LAYER["hatch"] without creating that layer, the same omission review found in Task 10's draw_annotation_prims. MEASURED live on AutoCAD 2026 (probe: GetActiveObject, Documents.Add(), doc.Close(False) in a finally): on a scratch document whose layer table is ['0'], msp.AddHatch(0, "ANSI31", False) + AppendOuterLoop + Evaluate() leaves the AcDbHatch in model space, and the very next statement hatch.Layer = "HATCH" - which is the one line _apply_entity_attrs runs - raises com_error (-2147352567, ..., 'Key not found', ..., -2145386476); model space then still reports [('AcDbHatch', '0')] and the layer table is still ['0'], so the refusal leaves an orphan hatch on layer 0. After doc.Layers.Add("HATCH") the same assignment succeeds. drawing_open runs no bootstrap, which is exactly when a cut face has no HATCH layer to land on. draw_material_hatch now calls ensure_annotation_layers before creating the hatch and reports layer / layers_created, as draw_annotation_prims does. The whole task re-measured live afterwards through ComBackend on a fresh scratch document (layer table ['0']): hatch_material -> {'ok': True, 'handle': '82', 'pattern': 'ANSI31', 'layer': 'HATCH', 'layers_created': ['HATCH']} with the entity read back as HATCH on HATCH; centre_marks on a live CIRCLE r=12.5 -> sources [{'handle': '84', 'center': [80.0, 0.0], 'radius': 12.5}]; section_line -> 9 entities and the five-key plane; layer table after ['0', 'CENTER', 'GEOMETRY', 'HATCH', 'TEXT']; document closed. tests/test_mech_marks.py: 17 passed. Gates: ruff check clean, inventory regenerated to 209 tools, test_tool_registry (engineering 12 -> 15, total 206 -> 209), test_discovery_aliases, test_tool_inventory, test_capability_contract and test_release_consistency all pass. Full suite: 3343 passed, the 6 pre-existing failures verified identical with this change stashed, and test_release_consistency.py::test_document_numbers_match_the_tree deselected as Global Constraints instructs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 4 +- discovery/aliases.py | 32 +++ docs/tool-inventory.json | 46 +++- engineering/mech/marks.py | 407 ++++++++++++++++++++++++++++++++++++ server.py | 162 +++++++++++++- tests/test_mech_marks.py | 336 +++++++++++++++++++++++++++++ tests/test_tool_registry.py | 13 +- 7 files changed, 989 insertions(+), 11 deletions(-) create mode 100644 engineering/mech/marks.py create mode 100644 tests/test_mech_marks.py diff --git a/README.md b/README.md index ce39366..22f0776 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 206 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> **v1.5 release snapshot:** 209 tools · 8 resources · 5 prompt templates · 3284 collected tests. > 204 is the **registered** count; a default install advertises 199 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. @@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -206 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +209 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index 18ca298..a225c53 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -372,6 +372,38 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "kose kaynagi", ), ), + "centre_marks": ToolAliases( + acad=("CENTERMARK", "CENTERLINE", "DIMCENTER"), + synonyms=( + "centre mark", + "center mark", + "centre line", + "center line of a hole", + "cross at the centre", + "eksen cizgisi", + "merkez isareti", + ), + ), + "section_line": ToolAliases( + acad=("SECTIONPLANE",), + synonyms=( + "cutting plane line", + "section line", + "section a-a", + "where to cut the part", + "view direction arrows", + "kesit cizgisi", + ), + ), + "hatch_material": ToolAliases( + synonyms=( + "material hatch", + "hatch as cast iron", + "section hatching for steel", + "hatch pattern for a material", + "malzeme taramasi", + ), + ), # ── Dimensions ────────────────────────────────────────────────────────── "dimension_aligned": ToolAliases( acad=("DIMALIGNED", "DAL"), diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index cdaa104..268e2ed 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 206, + "tools": 209, "resources": 8, "prompts": 5, "groups": 23 @@ -15,7 +15,7 @@ "corner_ops": 4, "dimensions": 5, "drawing": 11, - "engineering": 12, + "engineering": 15, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, @@ -343,6 +343,20 @@ "destructiveHint": true } }, + { + "name": "centre_marks", + "group": "engineering", + "cost": "mutate", + "summary": "Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.", + "tags": [ + "engineering", + "mech" + ], + "annotations": { + "title": "Centre Marks (ISO 128-23)", + "destructiveHint": false + } + }, { "name": "construction_clear", "group": "premium", @@ -1592,6 +1606,20 @@ "destructiveHint": false } }, + { + "name": "hatch_material", + "group": "engineering", + "cost": "mutate", + "summary": "Hatch a region with the ISO 128-50 section pattern for a named material.", + "tags": [ + "engineering", + "mech" + ], + "annotations": { + "title": "Material Hatch (ISO 128-50)", + "destructiveHint": false + } + }, { "name": "hatch_set_gradient", "group": "entity_creation", @@ -2212,6 +2240,20 @@ "readOnlyHint": true } }, + { + "name": "section_line", + "group": "engineering", + "cost": "mutate", + "summary": "Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.", + "tags": [ + "engineering", + "mech" + ], + "annotations": { + "title": "Section Line (ISO 128-40)", + "destructiveHint": false + } + }, { "name": "selection_filter", "group": "entity_query", diff --git a/engineering/mech/marks.py b/engineering/mech/marks.py new file mode 100644 index 0000000..a4eff9a --- /dev/null +++ b/engineering/mech/marks.py @@ -0,0 +1,407 @@ +"""Centre marks, the cutting-plane line and the material hatch. + +Pure geometry: these functions return primitive descriptions from +`engineering.mech.primitives`; the `draw_*` functions are the only async part +and the only part that touches a backend. + +SOURCE - centre marks and centre lines: ISO 128-23, "Technical drawings - +General principles of presentation - Lines on construction drawings", which +draws a centre line as a long-dash-dotted line that overruns the feature's +outline by a short amount. The *dash pattern itself is not written here*: the +prims are plain LINE entities on the CENTER layer, whose CENTER linetype +(bootstrapped by `drawing_new`) supplies the standard's dash proportions. That +is deliberate - transcribing a dash table this module cannot verify would put +an invented pattern next to a real one. + +SOURCE - the cutting-plane line: ISO 128-40, "Technical drawings - General +principles of presentation - Basic conventions for cuts and sections": a +long-dash-dotted line that is *wide* at its ends and at every change of +direction, an arrow at each end pointing in the direction of viewing, and the +same capital letter at both ends. Line width is a layer property here, so the +wide ends are emitted on the `visible` role (GEOMETRY, 0.50 mm) and the thin +middle on the `center` role (CENTER, 0.25 mm) - the ISO appearance, expressed +in the role vocabulary this repository already has. + +DECLARED, not transcribed: ISO 128-40 says the ends are thickened and that +arrows are placed there; it does not dimension them. The end-segment length, +the arrow shaft length, the arrowhead size and the label offset below are this +implementation's stated choices, expressed in text heights. + +The material hatch is not authored here at all: it comes from +`engineering.mech.standards.materials.hatch_for`, which carries the ISO 128-50 +map and its own refusal. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Line, Poly, Prim, Text + +if TYPE_CHECKING: # pragma: no cover - typing only + from backends.base import AutoCADBackend + +__all__ = [ + "CENTRE_STYLES", + "SECTION_STYLES", + "centre_mark_prims", + "draw_centre_marks", + "draw_material_hatch", + "draw_section_line", + "section_line_prims", + "section_plane", +] + +CENTRE_STYLES: tuple[str, ...] = ("mark", "lines") + +# The four styles `views.build_view(kind="section", style=...)` accepts; the +# dict this module returns is fed straight to it. +SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved") + +# DECLARED layout constants for the cutting-plane line, in text heights. +END_SEGMENT_FACTOR = 2.0 # wide segment at each end +ARROW_SHAFT_FACTOR = 2.0 # arrow shaft length, perpendicular to the plane +ARROW_HEAD_FACTOR = 0.8 # arrowhead length (half-width is 0.35 of it, as elsewhere) +LABEL_OFFSET_FACTOR = 0.7 # gap between the arrow tail and the label + + +def centre_mark_prims( + center: tuple[float, float], + radius: float, + *, + style: str = "mark", + extension: float = 3.0, +) -> tuple[Prim, ...]: + """A centre mark or a pair of full centre lines for a circular feature. + + `style="mark"` draws the short cross AutoCAD's DIMCEN draws: arms of + `extension` either side of the centre. `style="lines"` draws the two full + centre lines, overrunning the circle by `extension` at each end, which is + ISO 128-23's short overrun of the outline. + """ + if style not in CENTRE_STYLES: + raise ValueError( + f"centre style {style!r} is unknown; use one of {', '.join(CENTRE_STYLES)}." + ) + ext = float(extension) + if not math.isfinite(ext) or ext <= 0.0: + raise ValueError(f"extension must be a positive finite number; got {extension!r}.") + cx, cy = float(center[0]), float(center[1]) + if style == "mark": + arm = ext + else: + r = float(radius) + if not math.isfinite(r) or r <= 0.0: + raise ValueError( + f"style='lines' needs the feature's radius to know how far to overrun it; " + f"got radius={radius!r}." + ) + arm = r + ext + return ( + Line((cx - arm, cy), (cx + arm, cy), "center"), + Line((cx, cy - arm), (cx, cy + arm), "center"), + ) + + +def _unit(vx: float, vy: float, what: str) -> tuple[float, float]: + length = math.hypot(vx, vy) + if length <= 1e-12: + raise ValueError(f"{what} must be a non-zero vector; got ({vx:g}, {vy:g}).") + return vx / length, vy / length + + +def section_line_prims( + p1: tuple[float, float], + p2: tuple[float, float], + *, + label: str = "A", + direction: tuple[float, float] = (0.0, -1.0), + height: float = 5.0, +) -> tuple[Prim, ...]: + """The ISO 128-40 cutting-plane line: wide ends, arrows, the letter at both ends. + + `direction` is the direction of viewing and must not be parallel to the + plane. The arrowhead's tip sits on the end of the cutting-plane line and + its shaft extends away from the part, so the head reads as pointing the way + the section is viewed; the letter goes beyond the tail. + """ + if not str(label).strip(): + raise ValueError("a cutting-plane line needs a label; ISO 128-40 letters both ends.") + h = float(height) + if not math.isfinite(h) or h <= 0.0: + raise ValueError(f"height must be a positive finite number; got {height!r}.") + x1, y1 = float(p1[0]), float(p1[1]) + x2, y2 = float(p2[0]), float(p2[1]) + ux, uy = _unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)") + dx, dy = _unit(float(direction[0]), float(direction[1]), "direction") + if abs(ux * dy - uy * dx) < 1e-6: + raise ValueError( + f"direction ({dx:g}, {dy:g}) is parallel to the cutting plane; the viewing " + "direction must be perpendicular to it." + ) + + span = math.hypot(x2 - x1, y2 - y1) + end = END_SEGMENT_FACTOR * h + prims: list[Prim] = [] + if span <= 2.0 * end + 1e-9: + prims.append(Line((x1, y1), (x2, y2), "visible")) + else: + a = (x1 + ux * end, y1 + uy * end) + b = (x2 - ux * end, y2 - uy * end) + prims.append(Line((x1, y1), a, "visible")) + prims.append(Line(b, (x2, y2), "visible")) + prims.append(Line(a, b, "center")) + + shaft = ARROW_SHAFT_FACTOR * h + head = ARROW_HEAD_FACTOR * h + px, py = -dy, dx + text = str(label).strip() + for tip in ((x1, y1), (x2, y2)): + tail = (tip[0] - dx * shaft, tip[1] - dy * shaft) + prims.append(Line(tail, tip, "visible")) + base = (tip[0] - dx * head, tip[1] - dy * head) + prims.append( + Poly( + ( + tip, + (base[0] + px * head * 0.35, base[1] + py * head * 0.35), + (base[0] - px * head * 0.35, base[1] - py * head * 0.35), + ), + True, + "visible", + ) + ) + for tip in ((x1, y1), (x2, y2)): + offset = shaft + LABEL_OFFSET_FACTOR * h + prims.append(Text((tip[0] - dx * offset, tip[1] - dy * offset), text, h)) + return tuple(prims) + + +def section_plane( + p1: tuple[float, float], + p2: tuple[float, float], + *, + label: str = "A", + direction: tuple[float, float] = (0.0, -1.0), + style: str = "full", +) -> dict: + """The plane definition `mech_view_add(kind="section")` consumes. + + Exactly the five keys the plan pins as SECTION_PLANE, in that order, with + `direction` normalised to a unit vector. Returning it from the tool that + draws the cutting-plane line is what makes the label on the plan and the + label on the section view the same string by construction. + """ + if style not in SECTION_STYLES: + raise ValueError( + f"section style {style!r} is unknown; use one of {', '.join(SECTION_STYLES)}." + ) + text = str(label).strip() + if not text: + raise ValueError("a section plane needs a label; ISO 128-40 letters both ends.") + x1, y1 = float(p1[0]), float(p1[1]) + x2, y2 = float(p2[0]), float(p2[1]) + _unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)") + dx, dy = _unit(float(direction[0]), float(direction[1]), "direction") + return { + "p1": (x1, y1), + "p2": (x2, y2), + "label": text, + "direction": (dx, dy), + "style": style, + } + + +# ── the async layer ───────────────────────────────────────────────────────── + +_CIRCULAR_TYPES = ("CIRCLE", "ARC") + + +async def draw_centre_marks( + backend: AutoCADBackend, + *, + handles: list[str] | None = None, + centers: list[list[float]] | None = None, + style: str = "mark", + extension: float = 3.0, + layer: str | None = None, +) -> dict: + """Centre marks or centre lines for circular features. + + With `handles`, the circles' real centre and radius are read back out of + the drawing through `entity_get` - the caller is never asked to restate a + radius it could get wrong. With `centers`, each row is `[x, y]` or + `[x, y, radius]`; `style="lines"` needs the radius, because the overrun is + measured from the outline. + """ + from engineering.mech.annotate import draw_annotation_prims + + if not handles and not centers: + raise ValueError("centre_marks needs either handles or centers; both are empty.") + if handles and centers: + raise ValueError("centre_marks takes handles or centers, not both.") + + sources: list[dict] = [] + if handles: + for handle in handles: + info = await backend.entity_get(handle) + if info.type not in _CIRCULAR_TYPES: + raise ValueError( + f"handle {handle} is a {info.type}; a centre mark needs a circular " + f"feature ({', '.join(_CIRCULAR_TYPES)})." + ) + radius = info.properties.get("radius") + if radius is None: + raise ValueError( + f"handle {handle} lies in a plane tilted out of WCS XY, so it has no " + "radius in this frame (capability 'ocs_tilted_plane'); its centre mark " + "cannot be drawn in xy." + ) + center = info.properties["center"] + sources.append( + { + "handle": info.handle, + "center": [float(center[0]), float(center[1])], + "radius": float(radius), + } + ) + else: + for index, row in enumerate(centers or []): + if len(row) < 2: + raise ValueError(f"centers[{index}] must be [x, y] or [x, y, radius].") + radius = float(row[2]) if len(row) > 2 else None + if style == "lines" and radius is None: + raise ValueError( + f"centers[{index}] has no radius, and style='lines' measures its overrun " + "from the outline; pass [x, y, radius] or use style='mark'." + ) + sources.append( + { + "handle": None, + "center": [float(row[0]), float(row[1])], + "radius": radius, + } + ) + + # Validate every source before the first entity is written. + batches = [ + centre_mark_prims( + (source["center"][0], source["center"][1]), + source["radius"] if source["radius"] is not None else 0.0, + style=style, + extension=extension, + ) + for source in sources + ] + handles_out: list[str] = [] + for prims in batches: + drawn = await draw_annotation_prims(backend, prims, layer=layer) + handles_out.extend(drawn["handles"]) + return { + "ok": True, + "handles": handles_out, + "count": len(handles_out), + "style": style, + "sources": sources, + } + + +async def draw_section_line( + backend: AutoCADBackend, + p1: tuple[float, float], + p2: tuple[float, float], + *, + label: str = "A", + direction: tuple[float, float] = (0.0, -1.0), + style: str = "full", + height: float = 5.0, + layer: str | None = None, +) -> dict: + """Draw the cutting-plane line and hand back the plane the view engine consumes.""" + from engineering.mech.annotate import draw_annotation_prims + + plane = section_plane(p1, p2, label=label, direction=direction, style=style) + prims = section_line_prims( + plane["p1"], + plane["p2"], + label=plane["label"], + direction=plane["direction"], + height=height, + ) + result = await draw_annotation_prims(backend, prims, layer=layer) + result["plane"] = plane + result["label"] = plane["label"] + return result + + +async def draw_material_hatch( + backend: AutoCADBackend, + *, + boundary: list[list[float]] | None = None, + handles: list[str] | None = None, + material: str = "steel", + scale: float = 1.0, + angle: float | None = None, + layer: str | None = None, +) -> dict: + """Hatch a closed region with the ISO 128-50 pattern for a material. + + `hatch_for` owns the map and its refusal, so an unknown material name is + rejected here before anything is drawn. `scale` multiplies the material's + own pattern scale; `angle` replaces the material's own angle when given. + """ + from engineering.mech.annotate import ensure_annotation_layers + from engineering.mech.primitives import ROLE_LAYER as _ROLE_LAYER + from engineering.mech.standards.materials import hatch_for + + spec = hatch_for(material) # refuses an unknown material, naming the list + factor = float(scale) + if not math.isfinite(factor) or factor <= 0.0: + raise ValueError(f"scale must be a positive finite number; got {scale!r}.") + + if boundary is None and not handles: + raise ValueError("hatch_material needs either boundary points or handles; both are empty.") + if boundary is not None and handles: + raise ValueError("hatch_material takes boundary or handles, not both.") + + if boundary is not None: + points = [[float(p[0]), float(p[1])] for p in boundary] + if len(points) < 3: + raise ValueError(f"a hatch boundary needs at least three points; got {len(points)}.") + else: + loop = await backend.boundary_from_entities(list(handles or [])) + if not loop.get("ok"): + raise ValueError(f"hatch_material could not close a loop: {loop.get('error')}") + traced = await backend.entity_get(loop["handle"]) + points = [[float(p[0]), float(p[1])] for p in traced.properties["points"]] + + pattern_scale = float(spec["scale"]) * factor + pattern_angle = float(spec["angle"]) if angle is None else float(angle) + target = layer or _ROLE_LAYER["hatch"] + # MEASURED on AutoCAD 2026: `msp.AddHatch(...)` + `Evaluate()` puts the + # AcDbHatch in model space, and the next statement - `entity.Layer = "HATCH"` + # inside `_apply_entity_attrs` - raises `('Key not found')` on a drawing whose + # layer table is `['0']`, leaving that hatch orphaned on layer 0. ActiveX never + # creates the layer; `EzdxfBackend._apply_attrs` does, so without this the same + # call succeeds headless and half-writes live. `drawing_open` runs no bootstrap, + # which is exactly when a cut face has no HATCH layer to land on. + created = await ensure_annotation_layers(backend, [target]) + info = await backend.entity_create_hatch( + spec["pattern"], + points, + pattern_scale, + pattern_angle, + target, + ) + return { + "ok": True, + "handle": info.handle, + "material": material, + "pattern": spec["pattern"], + "scale": pattern_scale, + "angle": pattern_angle, + "points": len(points), + "layer": target, + "layers_created": list(created), + } diff --git a/server.py b/server.py index 85d820b..a9900df 100644 --- a/server.py +++ b/server.py @@ -8124,7 +8124,7 @@ async def drawing_properties_set( # --------------------------------------------------------------------------- -# ── SECTION 23: Mechanical Annotation (2 tools) ───────────────────────────── +# ── SECTION 23: Mechanical Annotation (5 tools) ───────────────────────────── # --------------------------------------------------------------------------- # ISO annotation symbols composed from LINE / ARC / CIRCLE / LWPOLYLINE / TEXT, # so the same symbol lands on COM and ezdxf. The standards tables live in @@ -8304,6 +8304,166 @@ async def weld_symbol( ) +@cad_tool( + summary="Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Centre Marks (ISO 128-23)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def centre_marks( + handles: Annotated[ + list[str] | None, + "Handles of CIRCLEs/ARCs; their real centre and radius are read from the drawing.", + ] = None, + centers: Annotated[ + list[list[float]] | None, + "Explicit centres as [x, y] or [x, y, radius]; style='lines' needs the radius.", + ] = None, + style: Annotated[ + str, + Field( + default="mark", + description="mark (short cross at the centre) | lines (full centre lines).", + ), + ] = "mark", + extension: Annotated[ + float, + Field( + default=3.0, + gt=0, + description="Arm length (mark) or overrun past the outline (lines), mm.", + ), + ] = 3.0, + layer: Annotated[str | None, "Override the CENTER role layer."] = None, + ctx: Context = None, +) -> dict: + """Centre marks on the CENTER layer, whose linetype carries ISO 128-23's dash pattern. + + Pass `handles` and the radii are read back with `entity_get` rather than + restated — a radius from memory is the classic silent wrong number. + Refusals, all before the first entity is written: neither `handles` nor + `centers` given (or both), a handle that is not a CIRCLE or ARC, a circle + in a plane tilted out of WCS XY (capability `ocs_tilted_plane`, so it has + no radius in this frame), a `style` outside mark/lines, a non-positive + `extension`, and `style='lines'` on a centre given without a radius. + """ + from engineering.mech.marks import draw_centre_marks + + await ctx.debug(f"Centre marks style={style}") + return await draw_centre_marks( + _backend(ctx), + handles=handles, + centers=centers, + style=style, + extension=extension, + layer=layer, + ) + + +@cad_tool( + summary="Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Section Line (ISO 128-40)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def section_line( + x1: Annotated[float, "Cutting plane start X (WCS)."], + y1: Annotated[float, "Cutting plane start Y (WCS)."], + x2: Annotated[float, "Cutting plane end X (WCS)."], + y2: Annotated[float, "Cutting plane end Y (WCS)."], + label: Annotated[str, "Capital letter drawn at both ends, e.g. 'A'."] = "A", + direction: Annotated[ + list[float] | None, + "Viewing direction as [dx, dy]; must not be parallel to the cutting plane.", + ] = None, + style: Annotated[ + str, "full | half | offset | revolved — carried into the returned plane." + ] = "full", + height: Annotated[float, Field(default=5.0, gt=0, description="Label height (mm).")] = 5.0, + layer: Annotated[str | None, "Override the role layers (GEOMETRY/CENTER/TEXT)."] = None, + ctx: Context = None, +) -> dict: + """Draw the cutting-plane line and hand back the plane a section view consumes. + + Wide segments at the ends on GEOMETRY, the thin long-dash-dot middle on + CENTER, an arrow at each end whose head sits on the line end and points the + way the section is viewed, and the same letter beyond both arrows. + `payload["plane"]` is exactly `{p1, p2, label, direction, style}` — feed it + to the section view so the label on the plan and the label on the view + cannot disagree. Refusals, before any entity is written: `p1 == p2`, a zero + or parallel viewing direction, an empty `label`, a `style` outside + full/half/offset/revolved, and a non-positive `height`. + """ + from engineering.mech.marks import draw_section_line + + await ctx.debug(f"Section line {label} ({x1},{y1})-({x2},{y2})") + return await draw_section_line( + _backend(ctx), + (x1, y1), + (x2, y2), + label=label, + direction=tuple(direction) if direction else (0.0, -1.0), + style=style, + height=height, + layer=layer, + ) + + +@cad_tool( + summary="Hatch a region with the ISO 128-50 section pattern for a named material.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Material Hatch (ISO 128-50)", "destructiveHint": False}, + tags={"engineering", "mech"}, +) +async def hatch_material( + material: Annotated[str, "Material name; an unknown one is refused with the list."] = "steel", + boundary: Annotated[ + list[list[float]] | None, "Closed boundary as [[x, y], ...] — at least three points." + ] = None, + handles: Annotated[ + list[str] | None, "Entities to chain into one closed loop instead of a boundary." + ] = None, + scale: Annotated[ + float, + Field(default=1.0, gt=0, description="Multiplies the material's own pattern scale."), + ] = 1.0, + angle: Annotated[ + float | None, "Replaces the material's own pattern angle (degrees) when given." + ] = None, + layer: Annotated[str | None, "Override the HATCH role layer."] = None, + ctx: Context = None, +) -> dict: + """Section-hatch a cut face with the pattern ISO 128-50 gives its material. + + Refusals: an unknown material (named, with the full list, before anything + is drawn), neither `boundary` nor `handles` (or both), a boundary with + fewer than three points, and a non-positive `scale`. With `handles` the + loop is chained by `boundary_from_entities`, which the COM engine refuses + (capability `boundary_trace`) — pass `boundary` on a live seat, or run + headlessly. The HATCH layer is created first when the drawing lacks it: + ActiveX refuses an entity on an absent layer and would otherwise leave the + hatch orphaned on layer 0. + """ + from engineering.mech.marks import draw_material_hatch + + await ctx.debug(f"Material hatch {material}") + return await draw_material_hatch( + _backend(ctx), + boundary=boundary, + handles=handles, + material=material, + scale=scale, + angle=angle, + layer=layer, + ) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_mech_marks.py b/tests/test_mech_marks.py new file mode 100644 index 0000000..63625cc --- /dev/null +++ b/tests/test_mech_marks.py @@ -0,0 +1,336 @@ +"""Centre marks (ISO 128-23), the cutting-plane line (ISO 128-40) and material hatch. + +Pure geometry asserted as emitted primitive tuples; the async layer against a +real ezdxf document through the `backend` fixture. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.marks import ( + CENTRE_STYLES, + centre_mark_prims, +) +from engineering.mech.primitives import Line + +pytestmark = pytest.mark.asyncio + +EPS = 1e-9 + + +def _flat(points): + """Flatten a point sequence: pytest.approx refuses nested structures.""" + return tuple(float(value) for point in points for value in point) + + +async def test_a_centre_mark_is_a_small_cross_on_the_centre_role(): + prims = centre_mark_prims((10.0, 20.0), 5.0, style="mark", extension=3.0) + assert len(prims) == 2 + horizontal, vertical = prims + assert isinstance(horizontal, Line) and isinstance(vertical, Line) + assert horizontal.p1 == pytest.approx((7.0, 20.0), abs=EPS) + assert horizontal.p2 == pytest.approx((13.0, 20.0), abs=EPS) + assert vertical.p1 == pytest.approx((10.0, 17.0), abs=EPS) + assert vertical.p2 == pytest.approx((10.0, 23.0), abs=EPS) + assert horizontal.role == "center" and vertical.role == "center" + + +async def test_centre_lines_cross_the_circle_and_overrun_it_by_the_extension(): + prims = centre_mark_prims((10.0, 20.0), 5.0, style="lines", extension=3.0) + horizontal, vertical = prims + assert horizontal.p1 == pytest.approx((2.0, 20.0), abs=EPS) + assert horizontal.p2 == pytest.approx((18.0, 20.0), abs=EPS) + assert vertical.p1 == pytest.approx((10.0, 12.0), abs=EPS) + assert vertical.p2 == pytest.approx((10.0, 28.0), abs=EPS) + assert horizontal.role == "center" and vertical.role == "center" + + +async def test_the_style_enum_is_closed_and_a_bad_radius_is_refused(): + assert CENTRE_STYLES == ("mark", "lines") + with pytest.raises(ValueError) as excinfo: + centre_mark_prims((0.0, 0.0), 5.0, style="cross") + assert "mark, lines" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + centre_mark_prims((0.0, 0.0), 0.0, style="lines") + assert "radius" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + centre_mark_prims((0.0, 0.0), 5.0, extension=0.0) + assert "extension" in str(excinfo.value) + + +# ── ISO 128-40 cutting-plane line ─────────────────────────────────────────── + +from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests + SECTION_STYLES, + section_line_prims, + section_plane, +) +from engineering.mech.primitives import Poly, Text # noqa: E402 + + +async def test_the_cutting_plane_line_is_thick_at_the_ends_and_thin_between(): + prims = section_line_prims( + (0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0 + ) + head_end, tail_end, middle = prims[0], prims[1], prims[2] + assert head_end.p1 == pytest.approx((0.0, 0.0), abs=EPS) + assert head_end.p2 == pytest.approx((10.0, 0.0), abs=EPS) + assert head_end.role == "visible" + assert tail_end.p1 == pytest.approx((90.0, 0.0), abs=EPS) + assert tail_end.p2 == pytest.approx((100.0, 0.0), abs=EPS) + assert tail_end.role == "visible" + assert middle.p1 == pytest.approx((10.0, 0.0), abs=EPS) + assert middle.p2 == pytest.approx((90.0, 0.0), abs=EPS) + assert middle.role == "center" + + +async def test_a_short_cutting_plane_is_one_wide_segment_with_no_thin_middle(): + prims = section_line_prims((0.0, 0.0), (15.0, 0.0), height=5.0) + assert prims[0].p1 == pytest.approx((0.0, 0.0), abs=EPS) + assert prims[0].p2 == pytest.approx((15.0, 0.0), abs=EPS) + assert prims[0].role == "visible" + assert not [p for p in prims if isinstance(p, Line) and p.role == "center"] + + +async def test_each_end_gets_an_arrow_pointing_the_viewing_direction_and_the_same_letter(): + prims = section_line_prims( + (0.0, 0.0), (100.0, 0.0), label="B", direction=(0.0, -1.0), height=5.0 + ) + shafts = [p for p in prims if isinstance(p, Line) and p.p1[0] == p.p2[0]] + assert len(shafts) == 2 + # The shaft comes from outside and the head's tip sits on the line end. + assert shafts[0].p1 == pytest.approx((0.0, 10.0), abs=EPS) + assert shafts[0].p2 == pytest.approx((0.0, 0.0), abs=EPS) + assert shafts[1].p1 == pytest.approx((100.0, 10.0), abs=EPS) + assert shafts[1].p2 == pytest.approx((100.0, 0.0), abs=EPS) + heads = [p for p in prims if isinstance(p, Poly)] + assert len(heads) == 2 and all(h.closed for h in heads) + assert _flat(heads[0].points) == pytest.approx((0.0, 0.0, 1.4, 4.0, -1.4, 4.0), abs=EPS) + labels = [p for p in prims if isinstance(p, Text)] + assert [t.text for t in labels] == ["B", "B"] + assert labels[0].at == pytest.approx((0.0, 13.5), abs=EPS) + assert labels[1].at == pytest.approx((100.0, 13.5), abs=EPS) + assert all(t.height == 5.0 and t.role == "text" for t in labels) + + +async def test_a_viewing_direction_parallel_to_the_plane_is_refused(): + with pytest.raises(ValueError) as excinfo: + section_line_prims((0.0, 0.0), (100.0, 0.0), direction=(1.0, 0.0)) + assert "perpendicular" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + section_line_prims((0.0, 0.0), (0.0, 0.0), direction=(0.0, -1.0)) + assert "distinct" in str(excinfo.value) + + +async def test_section_plane_is_exactly_the_dict_the_view_engine_consumes(): + plane = section_plane((0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -2.0)) + assert plane == { + "p1": (0.0, 0.0), + "p2": (100.0, 0.0), + "label": "A", + "direction": (0.0, -1.0), + "style": "full", + } + assert list(plane) == ["p1", "p2", "label", "direction", "style"] + assert SECTION_STYLES == ("full", "half", "offset", "revolved") + with pytest.raises(ValueError) as excinfo: + section_plane((0.0, 0.0), (10.0, 0.0), style="broken") + assert "full, half, offset, revolved" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + section_plane((0.0, 0.0), (10.0, 0.0), label="") + assert "label" in str(excinfo.value) + + +# ── the async layer ───────────────────────────────────────────────────────── + +from engineering.mech.marks import ( # noqa: E402 - grouped with its own tests + draw_centre_marks, + draw_material_hatch, + draw_section_line, +) +from engineering.mech.primitives import ROLE_LAYER # noqa: E402 +from engineering.mech.standards.materials import MATERIALS, hatch_for # noqa: E402 + + +async def test_centre_marks_read_the_circles_real_radius_from_the_drawing(backend): + small = await backend.entity_create_circle(0.0, 0.0, 4.0, "GEOMETRY") + large = await backend.entity_create_circle(50.0, 0.0, 12.0, "GEOMETRY") + result = await draw_centre_marks( + backend, handles=[small.handle, large.handle], style="lines", extension=2.0 + ) + assert result["ok"] is True and result["count"] == 4 + assert result["sources"] == [ + {"handle": small.handle, "center": [0.0, 0.0], "radius": 4.0}, + {"handle": large.handle, "center": [50.0, 0.0], "radius": 12.0}, + ] + first = await backend.entity_get(result["handles"][0]) + assert first.layer == ROLE_LAYER["center"] + assert first.properties["start"] == pytest.approx([-6.0, 0.0], abs=EPS) + assert first.properties["end"] == pytest.approx([6.0, 0.0], abs=EPS) + third = await backend.entity_get(result["handles"][2]) + assert third.properties["start"] == pytest.approx([36.0, 0.0], abs=EPS) + assert third.properties["end"] == pytest.approx([64.0, 0.0], abs=EPS) + + +async def test_centre_marks_refuse_a_handle_that_is_not_a_circular_feature(backend): + line = await backend.entity_create_line(0.0, 0.0, 10.0, 0.0, 0.0, 0.0, "GEOMETRY") + before = (await backend.analysis_stats())["total_entities"] + with pytest.raises(ValueError) as excinfo: + await draw_centre_marks(backend, handles=[line.handle]) + assert line.handle in str(excinfo.value) and "LINE" in str(excinfo.value) + assert (await backend.analysis_stats())["total_entities"] == before + + +async def test_explicit_centres_are_accepted_and_lines_need_a_radius(backend): + result = await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="mark", extension=3.0) + assert result["count"] == 2 and result["sources"] == [ + {"handle": None, "center": [5.0, 5.0], "radius": None} + ] + with pytest.raises(ValueError) as excinfo: + await draw_centre_marks(backend, centers=[[5.0, 5.0]], style="lines") + assert "centers[0]" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + await draw_centre_marks(backend) + assert "handles" in str(excinfo.value) and "centers" in str(excinfo.value) + + +async def test_draw_section_line_returns_the_plane_dict_the_view_engine_consumes(backend): + result = await draw_section_line( + backend, (0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0 + ) + assert result["ok"] is True + assert result["plane"] == { + "p1": (0.0, 0.0), + "p2": (100.0, 0.0), + "label": "A", + "direction": (0.0, -1.0), + "style": "full", + } + assert list(result["plane"]) == ["p1", "p2", "label", "direction", "style"] + layers = set() + for handle in result["handles"]: + layers.add((await backend.entity_get(handle)).layer) + assert layers == {ROLE_LAYER["visible"], ROLE_LAYER["center"], ROLE_LAYER["text"]} + + +async def test_material_hatch_uses_the_iso_128_50_map_and_scales_on_top_of_it(backend): + steel = hatch_for("steel") + result = await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [40.0, 0.0], [40.0, 20.0], [0.0, 20.0]], + material="steel", + scale=2.0, + ) + assert result["ok"] is True and result["material"] == "steel" + assert result["pattern"] == steel["pattern"] + assert result["scale"] == pytest.approx(steel["scale"] * 2.0, abs=EPS) + assert result["angle"] == pytest.approx(steel["angle"], abs=EPS) + hatch = backend._doc.entitydb.get(result["handle"]) + assert hatch.dxftype() == "HATCH" + assert hatch.dxf.pattern_name == steel["pattern"] + assert hatch.dxf.pattern_scale == pytest.approx(steel["scale"] * 2.0, abs=EPS) + assert hatch.dxf.pattern_angle == pytest.approx(steel["angle"], abs=EPS) + assert (await backend.entity_get(result["handle"])).layer == ROLE_LAYER["hatch"] + # An explicit angle overrides the material's own. + turned = await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], + material="steel", + angle=90.0, + ) + assert turned["angle"] == pytest.approx(90.0, abs=EPS) + + +async def test_an_unknown_material_is_refused_with_the_list_before_any_write(backend): + assert "steel" in MATERIALS + before = (await backend.analysis_stats())["total_entities"] + with pytest.raises(ValueError): + await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], + material="unobtainium", + ) + assert (await backend.analysis_stats())["total_entities"] == before + + +async def test_a_boundary_that_is_not_a_closed_polygon_is_refused(backend): + with pytest.raises(ValueError) as excinfo: + await draw_material_hatch(backend, boundary=[[0.0, 0.0], [10.0, 0.0]], material="steel") + assert "at least three" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + await draw_material_hatch(backend, material="steel") + assert "boundary" in str(excinfo.value) and "handles" in str(excinfo.value) + + +# ── the HATCH layer must exist before the hatch is created (live) ──────────── + +import types # noqa: E402 + + +class _StrictLayerHatchBackend: + """A backend that refuses a hatch on a layer the drawing does not have. + + MEASURED on AutoCAD 2026 (probe: GetActiveObject, `Documents.Add()`, a + scratch document whose layer table is `['0']`, `doc.Close(False)` in a + `finally:`): `msp.AddHatch(0, "ANSI31", False)` + `AppendOuterLoop` + + `Evaluate()` leaves the AcDbHatch in model space, and the very next + statement `hatch.Layer = "HATCH"` raises + `com_error (-2147352567, ..., 'Key not found', ..., -2145386476)`. Model + space then still reports `[('AcDbHatch', '0')]` and the layer table is + still `['0']` - the refusal leaves an orphan hatch behind. After + `doc.Layers.Add("HATCH")` the same assignment succeeds. `ComBackend` + reaches that assignment through `_apply_entity_attrs`, which is the one + line `entity.Layer = layer`, so this is the hatch spelling of the failure + `ensure_annotation_layers` already fixed for the annotation symbols. + + The fake models that measured refusal and nothing else: every member below + exists on the real `AutoCADBackend` with this signature. + """ + + def __init__(self, layers=("0",)): + self.layers = {name.lower(): name for name in layers} + self.calls: list[tuple] = [] + + async def layer_list(self): + self.calls.append(("layer_list",)) + return [types.SimpleNamespace(name=name) for name in self.layers.values()] + + async def layer_create(self, name, color=7, linetype="Continuous", lineweight=-3): + self.calls.append(("layer_create", name, color, linetype, lineweight)) + self.layers[name.lower()] = name + return types.SimpleNamespace(name=name) + + async def entity_create_hatch( + self, pattern, boundary_points, scale=1.0, angle=0.0, layer=None, color=None + ): + if layer is not None and layer.lower() not in self.layers: + raise RuntimeError(f"AutoCAD COM error (-0x7ffdfff7) ('Key not found'): {layer}") + self.calls.append(("HATCH", layer, pattern)) + return types.SimpleNamespace(handle="2F") + + +async def test_the_hatch_layer_is_created_before_the_hatch_is(): + backend = _StrictLayerHatchBackend() + result = await draw_material_hatch( + backend, + boundary=[[0.0, 0.0], [40.0, 0.0], [40.0, 20.0], [0.0, 20.0]], + material="steel", + ) + assert result["ok"] is True and result["handle"] == "2F" + assert result["layers_created"] == [ROLE_LAYER["hatch"]] + # The layer is created strictly before the hatch entity. + kinds = [call[0] for call in backend.calls] + assert kinds.index("layer_create") < kinds.index("HATCH") + # ENGINEERING_LAYERS' own definition, not a bare default. + create = next(c for c in backend.calls if c[0] == "layer_create") + assert create[1] == ROLE_LAYER["hatch"] + + +async def test_a_drawing_that_already_has_the_hatch_layer_is_not_rewritten(): + backend = _StrictLayerHatchBackend(layers=("0", "hatch")) + result = await draw_material_hatch( + backend, boundary=[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], material="steel" + ) + assert result["layers_created"] == [] + assert not [c for c in backend.calls if c[0] == "layer_create"] diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index d65fc77..2fc2533 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (206 tools, 23 groups). + """Frozen snapshot of the surface (209 tools, 23 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and @@ -531,9 +531,10 @@ async def test_tool_group_sizes_are_unchanged(): since the snapshot was taken. `engineering` moved 10 -> 12 when v1.6's SECTION 23 opened with - `surface_texture` / `weld_symbol`; both are tagged `engineering` and `mech`, - and `mech` is not in `_GROUP_TAG_PRIORITY`, so they file under - `engineering` rather than opening a group of their own. + `surface_texture` / `weld_symbol`, then 12 -> 15 when `centre_marks`, + `section_line` and `hatch_material` completed it; all five are tagged + `engineering` and `mech`, and `mech` is not in `_GROUP_TAG_PRIORITY`, so + they file under `engineering` rather than opening a group of their own. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -543,7 +544,7 @@ async def test_tool_group_sizes_are_unchanged(): "corner_ops": 4, "dimensions": 5, "drawing": 11, - "engineering": 12, + "engineering": 15, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, @@ -561,4 +562,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 206 + assert sum(sizes.values()) == 209