From 56e6ff7206f1f680740f9db53ad6818e9ba3decb Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:03:14 +0300 Subject: [PATCH 1/7] feat(sheet): ISO 5457 frames and ISO 7200 title blocks for A4-A0 SECTION 24 opens with sheet_frame and titleblock_apply. engineering/sheet/frames.py transcribes ISO 216's trimmed sizes, ISO 5457's 20 mm filing margin and 10 mm edges, the grid reference system (an even number of divisions per side, that side of the frame over 50 mm; letters from the top, numbers from the left), the centring marks (trimmed edge to 5 mm inside the frame) and the 10 x 5 mm corner trimming marks. Every one of those numbers is pinned by a test against the clause it comes from, because a wrong margin is visible on every drawing this server makes. engineering/sheet/titleblock.py draws the ISO 7200 block -- 180 mm wide, which is exactly the drawing-space width of a portrait A4, which is why one block fits every size -- and carries the ISO 128-30 projection-angle symbol. Which symbol is which is derived rather than remembered: first-angle projection puts the view seen from the left on the right, and the view of the cone from its small end is the small circle inside the large one, so first angle draws the cone left and the circles right. The old A3 module folds in. apply_iso_a3_titleblock now forwards to apply_titleblock(size="A3", projection=None, zones=False, marks=False), which is the geometry it always drew, and tests/test_engineering_titleblock.py passes unchanged -- that is the regression gate for the fold. No new ActiveX member is called: both tools reach the drawing only through contract members the A3 title block has used on both engines since v1.0 (entity_create_polyline / _line / _circle / _text, layout_list, layout_set_current), so there was nothing to probe live. Declared, not glossed: the zone divisions drawn here are equal, so ISO 5457's allowance for shorter corner fields is not implemented; the division counts, which are what a zone reference quotes, are the standard's. ISO 7200's full optional-field list is not transcribed, only the fields this block has a cell for. Coverage is A4-A0; an unknown size is refused by name with the five it has. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 8 +- discovery/aliases.py | 34 +++ docs/tool-inventory.json | 32 ++- engineering/__init__.py | 7 + engineering/sheet/__init__.py | 42 ++++ engineering/sheet/frames.py | 337 +++++++++++++++++++++++++++++ engineering/sheet/titleblock.py | 346 ++++++++++++++++++++++++++++++ engineering/titleblock.py | 362 +++++++------------------------- server.py | 172 +++++++++++++++ tests/test_sheet_frames.py | 268 +++++++++++++++++++++++ tests/test_sheet_titleblock.py | 190 +++++++++++++++++ tests/test_tool_registry.py | 15 +- 12 files changed, 1514 insertions(+), 299 deletions(-) create mode 100644 engineering/sheet/__init__.py create mode 100644 engineering/sheet/frames.py create mode 100644 engineering/sheet/titleblock.py create mode 100644 tests/test_sheet_frames.py create mode 100644 tests/test_sheet_titleblock.py diff --git a/README.md b/README.md index 6d056a1..9e50eda 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests. -> 204 is the **registered** count; a default install advertises 199 over `tools/list`, +> **v1.5 release snapshot:** 206 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 206 is the **registered** count; a default install advertises 201 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists **A big MCP server is expensive to be connected to.** The full catalog costs a client **59,315 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**. -**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **186 AutoCAD command names and 933 synonym phrases** covering all 204 tools. A test refuses to let a tool exist without one. +**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **187 AutoCAD command names and 958 synonym phrases** covering all 206 tools. A test refuses to let a tool exist without one. | Advertised surface | Tools seen | Idle cost | |---|---:|---:| @@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -204 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +206 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index f139c03..1b2dd45 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -930,6 +930,40 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "drawing header", ), ), + "sheet_frame": ToolAliases( + acad=("MVSETUP",), + synonyms=( + "sheet frame", + "drawing frame", + "drawing border", + "iso 5457", + "zone grid", + "grid reference system", + "centring marks", + "trimming marks", + "sheet margins", + "filing margin", + "pafta", + "cizim cercevesi", + ), + ), + "titleblock_apply": ToolAliases( + synonyms=( + "title block", + "iso 7200", + "drawing header", + "sheet title block", + "a0 title block", + "a1 title block", + "a2 title block", + "a4 title block", + "projection angle symbol", + "first angle symbol", + "third angle symbol", + "antet", + "baslik blogu", + ), + ), # ── Layers & linetypes ────────────────────────────────────────────────── "layer_create": ToolAliases( acad=("LAYER", "LA"), diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index c5c16f1..7615cd9 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 204, + "tools": 206, "resources": 8, "prompts": 5, "groups": 23 @@ -15,7 +15,7 @@ "corner_ops": 4, "dimensions": 5, "drawing": 11, - "engineering": 10, + "engineering": 12, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, @@ -2268,6 +2268,20 @@ "readOnlyHint": true } }, + { + "name": "sheet_frame", + "group": "engineering", + "cost": "mutate", + "summary": "Draw an ISO 5457 sheet frame with the zone grid and centring marks.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: ISO 5457 Frame", + "destructiveHint": false + } + }, { "name": "solid_boolean", "group": "solids", @@ -2597,6 +2611,20 @@ "readOnlyHint": false } }, + { + "name": "titleblock_apply", + "group": "engineering", + "cost": "mutate", + "summary": "Stamp an ISO 7200 title block (and frame) on any sheet from A4 to A0.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: ISO 7200 Title Block", + "destructiveHint": false + } + }, { "name": "titleblock_apply_iso_a3", "group": "engineering", diff --git a/engineering/__init__.py b/engineering/__init__.py index 7d384f2..94aac30 100644 --- a/engineering/__init__.py +++ b/engineering/__init__.py @@ -45,6 +45,8 @@ from .preflight import ( preflight_drawing, ) from .refiner import RefineResult, RefineRound, RepairAction, refine_drawing +from .sheet.frames import draw_sheet_frame, frame_metrics, frame_prims +from .sheet.titleblock import apply_titleblock, titleblock_prims from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock from .validator import DrawingValidator, ValidationFinding, ValidationResult @@ -92,4 +94,9 @@ __all__ = [ "RefineRound", "RepairAction", "refine_drawing", + "apply_titleblock", + "draw_sheet_frame", + "frame_metrics", + "frame_prims", + "titleblock_prims", ] diff --git a/engineering/sheet/__init__.py b/engineering/sheet/__init__.py new file mode 100644 index 0000000..bd227a3 --- /dev/null +++ b/engineering/sheet/__init__.py @@ -0,0 +1,42 @@ +"""The sheet standard: ISO 5457 frames, ISO 7200 title blocks, ISO 7573 parts +lists, ISO 6433 balloons and the delivery formats.""" + +from engineering.sheet.frames import ( + CENTRING_OVERSHOOT, + EDGE_MARGIN, + FILING_MARGIN, + SHEET_LAYER, + SHEET_TEXT_LAYER, + SHEETS, + TRIM_MARK_LONG, + TRIM_MARK_SHORT, + ZONE_DIVISIONS, + ZONE_MODULE, + draw_sheet_frame, + draw_sheet_prims, + enter_layout, + frame_metrics, + frame_prims, + sheet_size, + zone_divisions, +) + +__all__ = [ + "CENTRING_OVERSHOOT", + "EDGE_MARGIN", + "FILING_MARGIN", + "SHEETS", + "SHEET_LAYER", + "SHEET_TEXT_LAYER", + "TRIM_MARK_LONG", + "TRIM_MARK_SHORT", + "ZONE_DIVISIONS", + "ZONE_MODULE", + "draw_sheet_frame", + "draw_sheet_prims", + "enter_layout", + "frame_metrics", + "frame_prims", + "sheet_size", + "zone_divisions", +] diff --git a/engineering/sheet/frames.py b/engineering/sheet/frames.py new file mode 100644 index 0000000..0e8dffb --- /dev/null +++ b/engineering/sheet/frames.py @@ -0,0 +1,337 @@ +"""ISO 5457 drawing sheets: sizes, margins, frame, zone grid, centring and trim marks. + +SOURCE +------ +Sheet sizes ISO 216:2007, A series, trimmed sizes in millimetres. +Frame and margins ISO 5457:1999, 4.2 -- the drawing frame that bounds the + drawing space leaves a 20 mm filing margin on the binding + edge and 10 mm on the other three, for every size A4 to A0. +Centring marks ISO 5457:1999, 4.3 -- four marks at the middle of each side + of the trimmed sheet, running from the trimmed edge to 5 mm + inside the frame. +Grid reference ISO 5457:1999, 4.4 -- the number of divisions along a side is + an even number obtained from the length of that side of the + frame divided by 50 mm. Columns are numbered from the left, + rows are lettered from the top. +Trimming marks ISO 5457:1999, 4.5 -- one mark in each corner of the trimmed + sheet, two overlapping 10 mm x 5 mm rectangles. + +COVERAGE: A4, A3, A2, A1, A0. The B series, the elongated sizes (A3.1, A4.2, +...) and the untrimmed sizes are not transcribed; `sheet_size` refuses an +unknown name and lists the five it has. + +NOT IMPLEMENTED, deliberately, so nobody reads more into the geometry than is +in it: + +* ISO 5457's allowance for shorter *corner* fields in the grid. The divisions + drawn here are equal (frame side / n). The division COUNT -- the number a + zone reference quotes, and the thing that is visible on the sheet -- is the + standard's; the two end fields' boundary sits up to 5 mm from where a + corner-field layout would put it. +* The grid is drawn for A4 too, although ISO 5457 does not require one there. + Pass `zones=False`. +""" + +from __future__ import annotations + +import string +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "CENTRING_OVERSHOOT", + "EDGE_MARGIN", + "FILING_MARGIN", + "ORIENTATIONS", + "SHEETS", + "SHEET_LAYER", + "SHEET_ROLE_LAYER", + "SHEET_TEXT_LAYER", + "TRIM_MARK_LONG", + "TRIM_MARK_SHORT", + "ZONE_DIVISIONS", + "ZONE_MODULE", + "ZONE_TEXT_HEIGHT", + "centred_text", + "draw_sheet_frame", + "draw_sheet_prims", + "enter_layout", + "frame_metrics", + "frame_prims", + "sheet_size", + "zone_divisions", +] + +#: Portrait width x height in millimetres (ISO 216). Landscape swaps the pair. +SHEETS: dict[str, tuple[float, float]] = { + "A4": (210.0, 297.0), + "A3": (297.0, 420.0), + "A2": (420.0, 594.0), + "A1": (594.0, 841.0), + "A0": (841.0, 1189.0), +} + +FILING_MARGIN = 20.0 +EDGE_MARGIN = 10.0 +ZONE_MODULE = 50.0 +ZONE_TEXT_HEIGHT = 3.5 +CENTRING_OVERSHOOT = 5.0 +TRIM_MARK_LONG = 10.0 +TRIM_MARK_SHORT = 5.0 + +#: The sheet is not part geometry: borders, rules and marks go on TITLEBLOCK and +#: the lettering on TEXT, which is where `titleblock_apply_iso_a3` has always put +#: them and what `tests/test_engineering_titleblock.py` pins. +SHEET_LAYER = "TITLEBLOCK" +SHEET_TEXT_LAYER = "TEXT" + +SHEET_ROLE_LAYER: dict[str, str] = { + "visible": SHEET_LAYER, + "center": SHEET_LAYER, + "text": SHEET_TEXT_LAYER, +} + +#: (divisions along the long side, divisions along the short side), ISO 5457 4.4. +#: Each row reproduces the even-division rule applied to that side of the frame +#: -- `test_zone_division_counts_match_the_authored_table` asserts both, so the +#: table and the rule can never drift apart. +ZONE_DIVISIONS: dict[str, tuple[int, int]] = { + "A4": (6, 4), + "A3": (8, 6), + "A2": (12, 8), + "A1": (16, 12), + "A0": (24, 16), +} + +ORIENTATIONS = ("landscape", "portrait") +_LETTERS = string.ascii_uppercase + + +def sheet_size(size: str, orientation: str = "landscape") -> tuple[float, float]: + """Trimmed width x height in millimetres for a sheet name and orientation.""" + key = str(size).strip().upper() + if key not in SHEETS: + raise ValueError( + f"ISO 5457 sheet {size!r} is not in the table; covered sizes: {', '.join(SHEETS)}" + ) + if orientation not in ORIENTATIONS: + raise ValueError(f"orientation must be one of {ORIENTATIONS}, got {orientation!r}") + w, h = SHEETS[key] + return (h, w) if orientation == "landscape" else (w, h) + + +def zone_divisions(side: float) -> int: + """ISO 5457 4.4: an even number of divisions, `side` / 50 mm rounded to it. + + `round()` is banker's at an exact .5; no frame side of A4..A0 lands there + (the closest is A2 landscape's 400 / 100 = 4.0, which is exact), and a side + that did would round down -- the safer half of the ambiguity, because it + makes the fields longer rather than shorter. + """ + return max(int(2 * round(float(side) / (2.0 * ZONE_MODULE))), 2) + + +def frame_metrics(size: str, *, orientation: str = "landscape") -> dict: + """Every number the frame is built from, for a caller that wants to place + geometry inside it without re-deriving the margins.""" + w, h = sheet_size(size, orientation) + x0, y0 = FILING_MARGIN, EDGE_MARGIN + x1, y1 = w - EDGE_MARGIN, h - EDGE_MARGIN + columns = zone_divisions(x1 - x0) + rows = zone_divisions(y1 - y0) + if rows > len(_LETTERS): + raise ValueError(f"{size}: {rows} grid rows need more than {len(_LETTERS)} letters") + return { + "size": str(size).strip().upper(), + "orientation": orientation, + "sheet": [w, h], + "margins": { + "left": FILING_MARGIN, + "right": EDGE_MARGIN, + "top": EDGE_MARGIN, + "bottom": EDGE_MARGIN, + }, + "frame": [x0, y0, x1, y1], + "drawing_area": [x1 - x0, y1 - y0], + "centre": [w / 2.0, h / 2.0], + "zones": { + "columns": columns, + "rows": rows, + "column_width": (x1 - x0) / columns, + "row_height": (y1 - y0) / rows, + "numbers": [str(i + 1) for i in range(columns)], + "letters": [_LETTERS[i] for i in range(rows)], + }, + } + + +def _rect(x0: float, y0: float, x1: float, y1: float, role: str = "visible") -> Poly: + return Poly(points=((x0, y0), (x1, y0), (x1, y1), (x0, y1)), closed=True, role=role) + + +def centred_text(cx: float, cy: float, text: str, height: float) -> Text: + """`entity_create_text` carries no alignment parameter, so centring is done + by offsetting the left-baseline insertion point: 0.6 * height of advance per + character (the txt.shx / ISO 3098 width factor) and half a cap height.""" + return Text(at=(cx - 0.3 * height * len(text), cy - 0.5 * height), text=text, height=height) + + +def frame_prims( + size: str, + *, + orientation: str = "landscape", + zones: bool = True, + marks: bool = True, +) -> tuple[Prim, ...]: + """The ISO 5457 sheet as primitive descriptions, sheet-local, with (0, 0) at + the lower-left corner of the trimmed sheet. + + Order is pinned: [0] the trimmed sheet, [1] the drawing frame, then the zone + ticks, then the zone labels, then the four centring marks, then the eight + trimming rectangles. + """ + metrics = frame_metrics(size, orientation=orientation) + w, h = metrics["sheet"] + x0, y0, x1, y1 = metrics["frame"] + prims: list[Prim] = [_rect(0.0, 0.0, w, h), _rect(x0, y0, x1, y1)] + + if zones: + zone = metrics["zones"] + cw, rh = zone["column_width"], zone["row_height"] + for i in range(1, zone["columns"]): + x = x0 + i * cw + prims.append(Line(p1=(x, y1), p2=(x, h))) + prims.append(Line(p1=(x, 0.0), p2=(x, y0))) + for j in range(1, zone["rows"]): + y = y0 + j * rh + prims.append(Line(p1=(0.0, y), p2=(x0, y))) + prims.append(Line(p1=(x1, y), p2=(w, y))) + for i, label in enumerate(zone["numbers"]): + cx = x0 + (i + 0.5) * cw + prims.append(centred_text(cx, y1 + EDGE_MARGIN / 2.0, label, ZONE_TEXT_HEIGHT)) + prims.append(centred_text(cx, y0 / 2.0, label, ZONE_TEXT_HEIGHT)) + for j, label in enumerate(zone["letters"]): + # ISO 5457 4.4: rows are lettered from the top. + cy = y1 - (j + 0.5) * rh + prims.append(centred_text(FILING_MARGIN / 2.0, cy, label, ZONE_TEXT_HEIGHT)) + prims.append(centred_text(x1 + EDGE_MARGIN / 2.0, cy, label, ZONE_TEXT_HEIGHT)) + + if marks: + cx, cy = metrics["centre"] + prims.append(Line(p1=(0.0, cy), p2=(x0 + CENTRING_OVERSHOOT, cy), role="center")) + prims.append(Line(p1=(w, cy), p2=(x1 - CENTRING_OVERSHOOT, cy), role="center")) + prims.append(Line(p1=(cx, 0.0), p2=(cx, y0 + CENTRING_OVERSHOOT), role="center")) + prims.append(Line(p1=(cx, h), p2=(cx, y1 - CENTRING_OVERSHOOT), role="center")) + for sx, ox in ((1.0, 0.0), (-1.0, w)): + for sy, oy in ((1.0, 0.0), (-1.0, h)): + prims.append(_rect(ox, oy, ox + sx * TRIM_MARK_LONG, oy + sy * TRIM_MARK_SHORT)) + prims.append(_rect(ox, oy, ox + sx * TRIM_MARK_SHORT, oy + sy * TRIM_MARK_LONG)) + + return tuple(prims) + + +async def enter_layout(backend: AutoCADBackend, layout: str | None, *, caller: str) -> str: + """Make `layout` current and return the space to restore afterwards. + + A sheet border frames the printed sheet rather than the model, so every + sheet tool takes a layout; asking for a border must not silently leave the + caller standing on it, which is why the caller's space comes back. + """ + if layout is None: + return "Model" + listing = await backend.layout_list() + names = {str(entry) for entry in (listing.get("layouts") or [])} + if layout not in names: + raise RuntimeError( + f"{caller}: layout {layout!r} does not exist " + f"(have: {', '.join(sorted(names))}). Create it with layout_create first." + ) + previous = getattr(backend, "_current_space", "Model") + switched = await backend.layout_set_current(layout) + if not switched.get("ok", True): + raise RuntimeError(f"{caller}: {switched.get('error')}") + return previous + + +async def draw_sheet_prims( + backend: AutoCADBackend, + prims, + *, + origin: Pt = (0.0, 0.0), + layer_map: dict | None = None, +) -> list[str]: + """Draw sheet primitives at `origin` and return their handles, in order.""" + ox, oy = float(origin[0]), float(origin[1]) + layers = dict(SHEET_ROLE_LAYER if layer_map is None else layer_map) + handles: list[str] = [] + for prim in prims: + layer = layers.get(prim.role, SHEET_LAYER) + if isinstance(prim, Poly): + entity = await backend.entity_create_polyline( + points=[[ox + px, oy + py] for px, py in prim.points], + closed=prim.closed, + layer=layer, + ) + elif isinstance(prim, Line): + entity = await backend.entity_create_line( + ox + prim.p1[0], oy + prim.p1[1], ox + prim.p2[0], oy + prim.p2[1], layer=layer + ) + elif isinstance(prim, Circle): + entity = await backend.entity_create_circle( + ox + prim.center[0], oy + prim.center[1], prim.radius, layer=layer + ) + elif isinstance(prim, Text): + entity = await backend.entity_create_text( + text=prim.text, + x=ox + prim.at[0], + y=oy + prim.at[1], + height=prim.height, + rotation=prim.rotation, + layer=layer, + ) + else: + raise ValueError( + f"sheet primitives are Line, Circle, Poly and Text; got {type(prim).__name__}" + ) + handles.append(entity.handle) + return handles + + +async def draw_sheet_frame( + backend: AutoCADBackend, + size: str, + *, + orientation: str = "landscape", + zones: bool = True, + marks: bool = True, + origin: Pt = (0.0, 0.0), + layout: str | None = None, +) -> dict: + """Draw the ISO 5457 frame. Every refusal fires before the first entity.""" + metrics = frame_metrics(size, orientation=orientation) + prims = frame_prims(size, orientation=orientation, zones=zones, marks=marks) + previous = await enter_layout(backend, layout, caller="sheet_frame") + try: + handles = await draw_sheet_prims(backend, prims, origin=origin) + finally: + if layout is not None: + await backend.layout_set_current(previous) + ox, oy = float(origin[0]), float(origin[1]) + w, h = metrics["sheet"] + return { + "ok": True, + "size": metrics["size"], + "orientation": orientation, + "layout": layout or "Model", + "zones": zones, + "marks": marks, + "handles": handles, + "outer_border": handles[0], + "inner_border": handles[1], + "metrics": metrics, + "bbox": {"min": [ox, oy], "max": [ox + w, oy + h]}, + } diff --git a/engineering/sheet/titleblock.py b/engineering/sheet/titleblock.py new file mode 100644 index 0000000..8e662d5 --- /dev/null +++ b/engineering/sheet/titleblock.py @@ -0,0 +1,346 @@ +"""ISO 7200 title blocks for every ISO 5457 sheet from A4 to A0. + +SOURCE +------ +Width ISO 7200:2004 -- the title block is 180 mm wide and sits in the + lower right-hand corner of the drawing space, against the + frame. 180 mm is exactly the drawing-space width of a portrait + A4, which is why one block fits every size. +Data fields ISO 7200:2004 -- the mandatory fields are the legal owner + (`company`), the identification number (`drawing_no`), the + date of issue (`date`), the segment/sheet number (`sheet`) and + the title (`title`). The rest carried here are optional + fields: creator (`drawn_by`), approval person (`checked_by`), + version (`revision`), and the supplementary item data this + shop draws (`part_no`, `material`, `scale`, `units`). ISO + 7200's full list of optional fields is NOT transcribed -- only + the fields this block has a cell for. +Projection angle ISO 128-30 / ISO 5456-2 -- the truncated-cone symbol; see + `projection_symbol_prims` for the arrangement and where it + comes from. + +The row heights (20 / 15 / 15 / 10 mm) and the cell splits are this +repository's layout, not ISO 7200 values: the standard fixes the *data*, not +the ruling. They are exactly the ones `titleblock_apply_iso_a3` has drawn since +v1.0, so the A3 sheet this server makes has not moved. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text +from engineering.sheet.frames import ( + draw_sheet_prims, + enter_layout, + frame_metrics, + frame_prims, +) + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "LABEL_HEIGHT", + "LABEL_PAD_X", + "LABEL_PAD_Y", + "PROJECTION_ANGLES", + "ROW1_H", + "ROW2_H", + "ROW3_H", + "ROW4_H", + "ROW4_VALUE_PAD_Y", + "TB_HEIGHT", + "TB_WIDTH", + "TITLEBLOCK_FIELDS", + "TITLEBLOCK_RULE_COUNT", + "TITLE_HEIGHT", + "TITLE_TEXT_INDEX", + "VALUE_FIELDS", + "VALUE_HEIGHT", + "VALUE_PAD_X", + "VALUE_PAD_Y", + "TitleBlockMetadata", + "apply_titleblock", + "projection_symbol_prims", + "titleblock_origin", + "titleblock_prims", + "value_text_indices", +] + +TB_WIDTH = 180.0 +TB_HEIGHT = 60.0 + +ROW1_H = 20.0 # title +ROW2_H = 15.0 # drawing_no | revision | sheet +ROW3_H = 15.0 # part_no | material | scale +ROW4_H = 10.0 # drawn_by | checked_by | date | company + +LABEL_HEIGHT = 2.0 +VALUE_HEIGHT = 3.5 +TITLE_HEIGHT = 5.0 +LABEL_PAD_X = 1.5 +LABEL_PAD_Y = 1.5 +VALUE_PAD_X = 3.0 +VALUE_PAD_Y = 5.0 +ROW4_VALUE_PAD_Y = 1.5 + +PROJECTION_ANGLES = ("first", "third") +PROJECTION_CONE_LENGTH = 8.0 +PROJECTION_R = 4.0 +PROJECTION_SMALL_R = 2.0 +PROJECTION_GAP = 4.0 + +#: Field -> the label printed in its cell, in the order the cells are drawn. +VALUE_FIELDS: tuple[tuple[str, str], ...] = ( + ("drawing_no", "DWG NO"), + ("revision", "REV"), + ("sheet", "SHEET"), + ("part_no", "PART NO"), + ("material", "MATERIAL"), + ("scale", "SCALE"), + ("drawn_by", "DRAWN"), + ("checked_by", "CHECKED"), + ("date", "DATE"), + ("company", "COMPANY"), +) + +#: Every ISO 7200 data field this block carries. `units` has no cell of its own +#: (the sheet is millimetres and the drawing says so), but it round-trips in the +#: payload so a reader is never told the drawing is unitless. +TITLEBLOCK_FIELDS: tuple[str, ...] = ( + "title", + "drawing_no", + "part_no", + "material", + "scale", + "units", + "drawn_by", + "checked_by", + "date", + "sheet", + "revision", + "company", +) + +#: The block frame plus the ten rules, at the head of the primitive tuple. +TITLEBLOCK_RULE_COUNT = 11 +TITLE_TEXT_INDEX = 11 + + +@dataclass +class TitleBlockMetadata: + """ISO 7200 data fields. Strings, written verbatim -- no transformation.""" + + title: str + drawing_no: str + part_no: str = "" + material: str = "" + scale: str = "1:1" + units: str = "mm" + drawn_by: str = "" + checked_by: str = "" + date: str = "" + sheet: str = "1/1" + revision: str = "A" + company: str = "Anka-Makine" + + +def titleblock_origin(size: str, *, orientation: str = "landscape") -> Pt: + """Lower-left corner of the title block, sheet-local.""" + metrics = frame_metrics(size, orientation=orientation) + x0, y0, x1, _y1 = metrics["frame"] + if x1 - x0 < TB_WIDTH - 1e-9: + raise ValueError( + f"{metrics['size']} {orientation}: the drawing area is {x1 - x0:.1f} mm wide, " + f"narrower than the ISO 7200 title block ({TB_WIDTH:.0f} mm)" + ) + return (x1 - TB_WIDTH, y0) + + +def projection_symbol_prims(at: Pt, angle: str = "first") -> tuple[Prim, ...]: + """The ISO 128-30 projection-angle symbol, centred on `at`. + + Arrangement, and where it comes from. The symbol is a truncated cone with a + horizontal axis and its small end facing left. The first- and third-angle + symbols are mirror images about a vertical axis, and which is which follows + from the projection rule itself: first-angle projection puts the view seen + from the left ON THE RIGHT, and the view of this cone seen from the left is + the small circle inside the large one. So FIRST angle draws the cone on the + left and the concentric circles on the right; THIRD angle is its mirror. + + The proportions (large circle diameter 8, small 4, cone 8 mm long) are this + module's; ISO 5456-2's proportion table is not transcribed. The arrangement + is the part that can be *wrong* on a drawing, and it is derived above. + """ + if angle not in PROJECTION_ANGLES: + raise ValueError(f"projection angle must be one of {PROJECTION_ANGLES}, got {angle!r}") + cx, cy = float(at[0]), float(at[1]) + half = (PROJECTION_CONE_LENGTH + PROJECTION_GAP + 2 * PROJECTION_R) / 2.0 + sign = 1.0 if angle == "first" else -1.0 + cone_far = -half * sign + cone_near = cone_far + sign * PROJECTION_CONE_LENGTH + circles_x = (half - PROJECTION_R) * sign + cone = Poly( + points=( + (cx + cone_far, cy - PROJECTION_SMALL_R), + (cx + cone_near, cy - PROJECTION_R), + (cx + cone_near, cy + PROJECTION_R), + (cx + cone_far, cy + PROJECTION_SMALL_R), + ), + closed=True, + ) + axis = Line(p1=(cx - half - 1.0, cy), p2=(cx + half + 1.0, cy), role="center") + return ( + cone, + Circle(center=(cx + circles_x, cy), radius=PROJECTION_R), + Circle(center=(cx + circles_x, cy), radius=PROJECTION_SMALL_R), + axis, + ) + + +def value_text_indices() -> dict[str, int]: + """Field -> its index in the tuple `titleblock_prims` returns. + + The cells are drawn label-then-value, starting right after the title, so + the value of the i-th field is at TITLE_TEXT_INDEX + 2i + 2. + """ + return {field: TITLE_TEXT_INDEX + 2 * i + 2 for i, (field, _label) in enumerate(VALUE_FIELDS)} + + +def titleblock_prims( + size: str, + fields, + *, + orientation: str = "landscape", + projection: str | None = "first", +) -> tuple[Prim, ...]: + """The title block as primitive descriptions, sheet-local. + + Order is pinned and read back by `value_text_indices`: the block frame, the + ten rules, the title, then (label, value) per cell in `VALUE_FIELDS` order, + then the four projection-symbol primitives when `projection` is not None. + """ + meta = fields if isinstance(fields, TitleBlockMetadata) else TitleBlockMetadata(**fields) + if projection is not None and projection not in PROJECTION_ANGLES: + raise ValueError( + f"projection must be one of {PROJECTION_ANGLES} or None, got {projection!r}" + ) + tb_x0, tb_y0 = titleblock_origin(size, orientation=orientation) + tb_x1, tb_y1 = tb_x0 + TB_WIDTH, tb_y0 + TB_HEIGHT + row4_top = tb_y0 + ROW4_H + row3_top = row4_top + ROW3_H + row2_top = row3_top + ROW2_H + r2_split1 = tb_x0 + TB_WIDTH * 0.5 + r2_split2 = tb_x0 + TB_WIDTH * 0.75 + r3_split1 = tb_x0 + TB_WIDTH / 3.0 + r3_split2 = tb_x0 + TB_WIDTH * 2.0 / 3.0 + r4_split1 = tb_x0 + TB_WIDTH * 0.25 + r4_split2 = tb_x0 + TB_WIDTH * 0.50 + r4_split3 = tb_x0 + TB_WIDTH * 0.75 + + prims: list[Prim] = [ + Poly( + points=((tb_x0, tb_y0), (tb_x1, tb_y0), (tb_x1, tb_y1), (tb_x0, tb_y1)), + closed=True, + ), + Line(p1=(tb_x0, row4_top), p2=(tb_x1, row4_top)), + Line(p1=(tb_x0, row3_top), p2=(tb_x1, row3_top)), + Line(p1=(tb_x0, row2_top), p2=(tb_x1, row2_top)), + Line(p1=(r2_split1, row3_top), p2=(r2_split1, row2_top)), + Line(p1=(r2_split2, row3_top), p2=(r2_split2, row2_top)), + Line(p1=(r3_split1, row4_top), p2=(r3_split1, row3_top)), + Line(p1=(r3_split2, row4_top), p2=(r3_split2, row3_top)), + Line(p1=(r4_split1, tb_y0), p2=(r4_split1, row4_top)), + Line(p1=(r4_split2, tb_y0), p2=(r4_split2, row4_top)), + Line(p1=(r4_split3, tb_y0), p2=(r4_split3, row4_top)), + ] + + title_x = tb_x0 + TB_WIDTH * 0.5 - (len(meta.title) * TITLE_HEIGHT * 0.3) + title_y = row2_top + (ROW1_H - TITLE_HEIGHT) / 2.0 + prims.append(Text(at=(title_x, title_y), text=meta.title, height=TITLE_HEIGHT)) + + # field -> (cell left edge, the row's top rule, the value's baseline) + cells: dict[str, tuple[float, float, float]] = { + "drawing_no": (tb_x0, row2_top, row3_top + VALUE_PAD_Y), + "revision": (r2_split1, row2_top, row3_top + VALUE_PAD_Y), + "sheet": (r2_split2, row2_top, row3_top + VALUE_PAD_Y), + "part_no": (tb_x0, row3_top, row4_top + VALUE_PAD_Y), + "material": (r3_split1, row3_top, row4_top + VALUE_PAD_Y), + "scale": (r3_split2, row3_top, row4_top + VALUE_PAD_Y), + "drawn_by": (tb_x0, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + "checked_by": (r4_split1, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + "date": (r4_split2, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + "company": (r4_split3, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + } + for field, label in VALUE_FIELDS: + left, row_top, value_y = cells[field] + prims.append( + Text( + at=(left + LABEL_PAD_X, row_top - LABEL_HEIGHT - LABEL_PAD_Y), + text=label, + height=LABEL_HEIGHT, + ) + ) + prims.append( + Text(at=(left + VALUE_PAD_X, value_y), text=getattr(meta, field), height=VALUE_HEIGHT) + ) + + if projection is not None: + prims.extend(projection_symbol_prims((tb_x0 + 15.0, row2_top + ROW1_H / 2.0), projection)) + return tuple(prims) + + +async def apply_titleblock( + backend: AutoCADBackend, + *, + size: str = "A3", + metadata: TitleBlockMetadata, + origin: Pt = (0.0, 0.0), + orientation: str = "landscape", + projection: str | None = "first", + frame: bool = True, + zones: bool = False, + marks: bool = False, + layout: str | None = None, +) -> dict: + """Draw the ISO 5457 frame (optionally) and the ISO 7200 title block. + + `projection=None`, `zones=False` and `marks=False` reproduce exactly what + `titleblock_apply_iso_a3` has always drawn. Every refusal -- an unknown + size, an unknown orientation, an unknown projection angle, a missing layout + -- fires before the first entity reaches the drawing. + """ + metrics = frame_metrics(size, orientation=orientation) + tb_prims = titleblock_prims(size, metadata, orientation=orientation, projection=projection) + sheet_prims = ( + frame_prims(size, orientation=orientation, zones=zones, marks=marks) if frame else () + ) + previous = await enter_layout(backend, layout, caller="titleblock_apply") + try: + frame_handles = await draw_sheet_prims(backend, sheet_prims, origin=origin) + tb_handles = await draw_sheet_prims(backend, tb_prims, origin=origin) + finally: + if layout is not None: + await backend.layout_set_current(previous) + + ox, oy = float(origin[0]), float(origin[1]) + w, h = metrics["sheet"] + symbol_start = TITLE_TEXT_INDEX + 1 + 2 * len(VALUE_FIELDS) + return { + "ok": True, + "size": metrics["size"], + "orientation": orientation, + "layout": layout or "Model", + "projection": projection, + "outer_border": frame_handles[0] if frame else None, + "inner_border": frame_handles[1] if frame else None, + "frame_handles": frame_handles, + "titleblock_lines": tb_handles[:TITLEBLOCK_RULE_COUNT], + "title_text": tb_handles[TITLE_TEXT_INDEX], + "value_texts": {f: tb_handles[i] for f, i in value_text_indices().items()}, + "projection_symbol": tb_handles[symbol_start:], + "metadata": asdict(metadata), + "bbox": {"min": [ox, oy], "max": [ox + w, oy + h]}, + } diff --git a/engineering/titleblock.py b/engineering/titleblock.py index 5bc39d6..d2d233e 100644 --- a/engineering/titleblock.py +++ b/engineering/titleblock.py @@ -1,57 +1,71 @@ -"""ISO 7200 / A3 (420 x 297 mm) title block generator. +"""ISO 7200 / A3 title block -- the historical entry point. -Deterministic — the title text is written verbatim from metadata. +The layout moved to `engineering.sheet.titleblock`, which draws every ISO 5457 +size from A4 to A0. What is left here is the A3 alias: `apply_iso_a3_titleblock` +forwards to `apply_titleblock(size="A3", projection=None, zones=False, +marks=False)`, which is bit-for-bit the geometry this module drew before -- no +projection-angle symbol, no zone grid, no trimming marks -- so every caller and +every test that pinned it keeps working. The module-level constants are +re-exported for the same reason. """ from __future__ import annotations -from dataclasses import asdict, dataclass from typing import TYPE_CHECKING +from engineering.sheet.frames import ( + EDGE_MARGIN, + FILING_MARGIN, + SHEET_LAYER, + SHEET_TEXT_LAYER, + sheet_size, +) +from engineering.sheet.titleblock import ( + LABEL_HEIGHT, + ROW1_H, + ROW2_H, + ROW3_H, + ROW4_H, + TB_HEIGHT, + TB_WIDTH, + TITLE_HEIGHT, + VALUE_HEIGHT, + TitleBlockMetadata, + apply_titleblock, +) + if TYPE_CHECKING: from backends.base import AutoCADBackend +__all__ = [ + "BOTTOM_MARGIN", + "LABEL_HEIGHT", + "LAYER_BORDER", + "LAYER_TEXT", + "LEFT_MARGIN", + "RIGHT_MARGIN", + "ROW1_H", + "ROW2_H", + "ROW3_H", + "ROW4_H", + "SHEET_H", + "SHEET_W", + "TB_HEIGHT", + "TB_WIDTH", + "TITLE_HEIGHT", + "TOP_MARGIN", + "VALUE_HEIGHT", + "TitleBlockMetadata", + "apply_iso_a3_titleblock", +] -# A3 paper, ISO 7200 layout constants (mm) -SHEET_W = 420.0 -SHEET_H = 297.0 -LEFT_MARGIN = 20.0 -RIGHT_MARGIN = 10.0 -TOP_MARGIN = 10.0 -BOTTOM_MARGIN = 10.0 - -TB_WIDTH = 180.0 -TB_HEIGHT = 60.0 - -ROW1_H = 20.0 # title -ROW2_H = 15.0 # drawing_no | revision | sheet -ROW3_H = 15.0 # part_no | material | scale -ROW4_H = 10.0 # drawn_by | checked_by | date | company - -LABEL_HEIGHT = 2.0 -VALUE_HEIGHT = 3.5 -TITLE_HEIGHT = 5.0 - -LAYER_BORDER = "TITLEBLOCK" -LAYER_TEXT = "TEXT" - - -@dataclass -class TitleBlockMetadata: - """ISO 7200 title block fields (all strings — no LLM transformation).""" - - title: str - drawing_no: str - part_no: str = "" - material: str = "" - scale: str = "1:1" - units: str = "mm" - drawn_by: str = "" - checked_by: str = "" - date: str = "" - sheet: str = "1/1" - revision: str = "A" - company: str = "Anka-Makine" +SHEET_W, SHEET_H = sheet_size("A3", "landscape") +LEFT_MARGIN = FILING_MARGIN +RIGHT_MARGIN = EDGE_MARGIN +TOP_MARGIN = EDGE_MARGIN +BOTTOM_MARGIN = EDGE_MARGIN +LAYER_BORDER = SHEET_LAYER +LAYER_TEXT = SHEET_TEXT_LAYER async def apply_iso_a3_titleblock( @@ -63,255 +77,25 @@ async def apply_iso_a3_titleblock( ) -> dict: """Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet). - With `layout`, the sheet is drawn on that paper-space layout — which is + With `layout`, the sheet is drawn on that paper-space layout -- which is where a title block belongs, since the border frames the printed sheet rather than the model. The caller's current space is restored afterwards, so asking for a sheet border does not silently move them onto the sheet. Without `layout` it draws in whatever space is current, as it always did. + + Equivalent to `titleblock_apply(size="A3")` with the projection-angle + symbol, the zone grid and the trimming marks switched off. Reach for + `titleblock_apply` for any other size, or for the symbol. """ - - ox, oy = float(origin[0]), float(origin[1]) - - previous_space = "Model" - if layout is not None: - listing = await backend.layout_list() - names = {str(entry) for entry in (listing.get("layouts") or [])} - if layout not in names: - raise RuntimeError( - f"apply_iso_a3_titleblock: layout {layout!r} does not exist " - f"(have: {', '.join(sorted(str(n) for n in names))}). Create it with " - "layout_create first." - ) - previous_space = getattr(backend, "_current_space", "Model") - switched = await backend.layout_set_current(layout) - if not switched.get("ok", True): - raise RuntimeError(f"apply_iso_a3_titleblock: {switched.get('error')}") - - try: - return await _draw_titleblock(backend, metadata=metadata, ox=ox, oy=oy, layout=layout) - finally: - if layout is not None: - await backend.layout_set_current(previous_space) - - -async def _draw_titleblock( - backend: AutoCADBackend, - *, - metadata: TitleBlockMetadata, - ox: float, - oy: float, - layout: str | None, -) -> dict: - - # ---- outer + inner borders ------------------------------------------------- - outer_pts = [ - [ox, oy], - [ox + SHEET_W, oy], - [ox + SHEET_W, oy + SHEET_H], - [ox, oy + SHEET_H], - ] - outer = await backend.entity_create_polyline( - points=outer_pts, - closed=True, - layer=LAYER_BORDER, + return await apply_titleblock( + backend, + size="A3", + metadata=metadata, + origin=origin, + orientation="landscape", + projection=None, + frame=True, + zones=False, + marks=False, + layout=layout, ) - - inner_x0 = ox + LEFT_MARGIN - inner_y0 = oy + BOTTOM_MARGIN - inner_x1 = ox + SHEET_W - RIGHT_MARGIN - inner_y1 = oy + SHEET_H - TOP_MARGIN - inner_pts = [ - [inner_x0, inner_y0], - [inner_x1, inner_y0], - [inner_x1, inner_y1], - [inner_x0, inner_y1], - ] - inner = await backend.entity_create_polyline( - points=inner_pts, - closed=True, - layer=LAYER_BORDER, - ) - - # ---- title block (lower-right corner of inner border) ---------------------- - tb_x0 = inner_x1 - TB_WIDTH - tb_y0 = inner_y0 - tb_x1 = inner_x1 - tb_y1 = inner_y0 + TB_HEIGHT - - # Title block frame (closed polyline) - tb_frame_pts = [ - [tb_x0, tb_y0], - [tb_x1, tb_y0], - [tb_x1, tb_y1], - [tb_x0, tb_y1], - ] - tb_frame = await backend.entity_create_polyline( - points=tb_frame_pts, - closed=True, - layer=LAYER_BORDER, - ) - - # Row baselines (from bottom up): row4_top, row3_top, row2_top - row4_top = tb_y0 + ROW4_H - row3_top = row4_top + ROW3_H - row2_top = row3_top + ROW2_H # == tb_y1 - ROW1_H - - titleblock_lines: list[str] = [tb_frame.handle] - - async def hline(y: float) -> str: - ent = await backend.entity_create_line( - tb_x0, - y, - tb_x1, - y, - layer=LAYER_BORDER, - ) - return ent.handle - - async def vline(x: float, y0: float, y1: float) -> str: - ent = await backend.entity_create_line( - x, - y0, - x, - y1, - layer=LAYER_BORDER, - ) - return ent.handle - - titleblock_lines.append(await hline(row4_top)) - titleblock_lines.append(await hline(row3_top)) - titleblock_lines.append(await hline(row2_top)) - - # Row 2 splits: left half = drawing_no, then quarter rev, quarter sheet - r2_split1 = tb_x0 + TB_WIDTH * 0.5 - r2_split2 = tb_x0 + TB_WIDTH * 0.75 - titleblock_lines.append(await vline(r2_split1, row3_top, row2_top)) - titleblock_lines.append(await vline(r2_split2, row3_top, row2_top)) - - # Row 3 splits: thirds — part_no | material | scale - r3_split1 = tb_x0 + TB_WIDTH / 3.0 - r3_split2 = tb_x0 + TB_WIDTH * 2.0 / 3.0 - titleblock_lines.append(await vline(r3_split1, row4_top, row3_top)) - titleblock_lines.append(await vline(r3_split2, row4_top, row3_top)) - - # Row 4 splits: quarters — drawn_by | checked_by | date | company - r4_split1 = tb_x0 + TB_WIDTH * 0.25 - r4_split2 = tb_x0 + TB_WIDTH * 0.50 - r4_split3 = tb_x0 + TB_WIDTH * 0.75 - titleblock_lines.append(await vline(r4_split1, tb_y0, row4_top)) - titleblock_lines.append(await vline(r4_split2, tb_y0, row4_top)) - titleblock_lines.append(await vline(r4_split3, tb_y0, row4_top)) - - # ---- text content ---------------------------------------------------------- - label_pad_x = 1.5 - label_pad_y = 1.5 - value_pad_x = 3.0 - value_pad_y = 5.0 - - async def label(text: str, x: float, y: float) -> str: - ent = await backend.entity_create_text( - text=text, - x=x, - y=y, - height=LABEL_HEIGHT, - layer=LAYER_TEXT, - ) - return ent.handle - - async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str: - ent = await backend.entity_create_text( - text=text, - x=x, - y=y, - height=height, - layer=LAYER_TEXT, - ) - return ent.handle - - # Row 1 — TITLE (verbatim) - title_x = tb_x0 + TB_WIDTH * 0.5 - (len(metadata.title) * TITLE_HEIGHT * 0.3) - title_y = row2_top + (ROW1_H - TITLE_HEIGHT) / 2.0 - title_text_handle = await value(metadata.title, title_x, title_y, height=TITLE_HEIGHT) - - value_texts: dict[str, str] = {} - - # Row 2: drawing_no (left half) | revision (3rd qtr) | sheet (4th qtr) - await label("DWG NO", tb_x0 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y) - value_texts["drawing_no"] = await value( - metadata.drawing_no, - tb_x0 + value_pad_x, - row3_top + value_pad_y, - ) - await label("REV", r2_split1 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y) - value_texts["revision"] = await value( - metadata.revision, - r2_split1 + value_pad_x, - row3_top + value_pad_y, - ) - await label("SHEET", r2_split2 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y) - value_texts["sheet"] = await value( - metadata.sheet, - r2_split2 + value_pad_x, - row3_top + value_pad_y, - ) - - # Row 3: part_no | material | scale - await label("PART NO", tb_x0 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y) - value_texts["part_no"] = await value( - metadata.part_no, - tb_x0 + value_pad_x, - row4_top + value_pad_y, - ) - await label("MATERIAL", r3_split1 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y) - value_texts["material"] = await value( - metadata.material, - r3_split1 + value_pad_x, - row4_top + value_pad_y, - ) - await label("SCALE", r3_split2 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y) - value_texts["scale"] = await value( - metadata.scale, - r3_split2 + value_pad_x, - row4_top + value_pad_y, - ) - - # Row 4: drawn_by | checked_by | date | company - await label("DRAWN", tb_x0 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y) - value_texts["drawn_by"] = await value( - metadata.drawn_by, - tb_x0 + value_pad_x, - tb_y0 + 1.5, - ) - await label("CHECKED", r4_split1 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y) - value_texts["checked_by"] = await value( - metadata.checked_by, - r4_split1 + value_pad_x, - tb_y0 + 1.5, - ) - await label("DATE", r4_split2 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y) - value_texts["date"] = await value( - metadata.date, - r4_split2 + value_pad_x, - tb_y0 + 1.5, - ) - await label("COMPANY", r4_split3 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y) - value_texts["company"] = await value( - metadata.company, - r4_split3 + value_pad_x, - tb_y0 + 1.5, - ) - - return { - "ok": True, - "layout": layout or "Model", - "outer_border": outer.handle, - "inner_border": inner.handle, - "titleblock_lines": titleblock_lines, - "title_text": title_text_handle, - "value_texts": value_texts, - "metadata": asdict(metadata), - "bbox": { - "min": [ox, oy], - "max": [ox + SHEET_W, oy + SHEET_H], - }, - } diff --git a/server.py b/server.py index 1635d7e..82b37bc 100644 --- a/server.py +++ b/server.py @@ -8123,6 +8123,178 @@ async def drawing_properties_set( return await _backend(ctx).drawing_properties_set(summary, custom) +# --------------------------------------------------------------------------- +# ── SECTION 24: Sheet & Delivery (2 tools) ────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Draw an ISO 5457 sheet frame with the zone grid and centring marks.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Sheet: ISO 5457 Frame", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def sheet_frame( + size: Annotated[ + str, Field(default="A3", description="ISO 216 sheet: A4, A3, A2, A1 or A0.") + ] = "A3", + orientation: Annotated[ + str, Field(default="landscape", description="'landscape' or 'portrait'.") + ] = "landscape", + zones: Annotated[ + bool, + Field( + default=True, + description="Draw the ISO 5457 grid reference system: letters down the vertical " + "edges from the top, numbers along the horizontal edges from the left.", + ), + ] = True, + marks: Annotated[ + bool, + Field( + default=True, + description="Draw the four centring marks and the eight corner trimming rectangles.", + ), + ] = True, + origin_x: Annotated[ + float, Field(default=0.0, description="X of the sheet's lower-left corner (WCS).") + ] = 0.0, + origin_y: Annotated[ + float, Field(default=0.0, description="Y of the sheet's lower-left corner (WCS).") + ] = 0.0, + layout: Annotated[ + str, + Field( + default="", + description="Paper-space layout to draw the sheet on (create it with " + "layout_create). Empty draws in the current space.", + ), + ] = "", + ctx: Context = None, +) -> dict: + """ISO 5457 drawing frame for A4-A0: a 20 mm filing margin, 10 mm on the + other three edges, the grid reference system and the centring/trimming marks. + + Borders, rules and marks land on TITLEBLOCK; the zone lettering on TEXT. + Refusals, all before the first entity is drawn: a size outside A4-A0 is + refused by name and the five covered sizes are listed; an orientation that + is neither 'landscape' nor 'portrait'; a `layout` that does not exist. The + zone divisions drawn are equal -- ISO 5457's shorter corner fields are not + implemented -- while the division counts are the standard's. + """ + from engineering.sheet.frames import draw_sheet_frame + + await ctx.info(f"Sheet frame {size} {orientation}") + return await draw_sheet_frame( + _backend(ctx), + size, + orientation=orientation, + zones=zones, + marks=marks, + origin=(origin_x, origin_y), + layout=layout or None, + ) + + +@cad_tool( + summary="Stamp an ISO 7200 title block (and frame) on any sheet from A4 to A0.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Sheet: ISO 7200 Title Block", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def titleblock_apply( + title: Annotated[str, Field(description="Drawing title (verbatim, no transformation).")], + drawing_no: Annotated[str, Field(description="Identification number, e.g. 'AM-2026-001'.")], + size: Annotated[ + str, Field(default="A3", description="ISO 216 sheet: A4, A3, A2, A1 or A0.") + ] = "A3", + orientation: Annotated[ + str, Field(default="landscape", description="'landscape' or 'portrait'.") + ] = "landscape", + projection: Annotated[ + str, + Field( + default="first", + description="Projection-angle symbol: 'first' (ISO/European, the default), " + "'third' (ANSI), or '' for no symbol.", + ), + ] = "first", + part_no: Annotated[str, Field(default="")] = "", + material: Annotated[str, Field(default="")] = "", + scale: Annotated[str, Field(default="1:1")] = "1:1", + units: Annotated[str, Field(default="mm")] = "mm", + drawn_by: Annotated[str, Field(default="")] = "", + checked_by: Annotated[str, Field(default="")] = "", + date: Annotated[str, Field(default="")] = "", + sheet: Annotated[str, Field(default="1/1")] = "1/1", + revision: Annotated[str, Field(default="A")] = "A", + company: Annotated[str, Field(default="Anka-Makine")] = "Anka-Makine", + frame: Annotated[ + bool, Field(default=True, description="Also draw the ISO 5457 frame around the block.") + ] = True, + zones: Annotated[ + bool, Field(default=False, description="Add the zone grid to that frame.") + ] = False, + marks: Annotated[ + bool, Field(default=False, description="Add the centring and trimming marks.") + ] = False, + origin_x: Annotated[float, Field(default=0.0)] = 0.0, + origin_y: Annotated[float, Field(default=0.0)] = 0.0, + layout: Annotated[ + str, + Field( + default="", + description="Paper-space layout to draw the sheet on; empty draws in the " + "current space.", + ), + ] = "", + ctx: Context = None, +) -> dict: + """ISO 7200 title block, 180 mm wide, in the lower-right corner of any + A4-A0 sheet. The title text is used verbatim. + + Carries the ISO 128-30 projection-angle symbol: 'first' draws the cone on + the left and the concentric circles on the right, 'third' is its mirror. + Refusals, all before the first entity: an unknown size (the five covered + sizes are listed), an unknown orientation, a projection that is not + 'first'/'third'/'', a `layout` that does not exist. `titleblock_apply_iso_a3` + is this tool with size='A3' and no symbol, kept so no caller breaks. + """ + from engineering.sheet.titleblock import TitleBlockMetadata, apply_titleblock + + await ctx.info(f"Title block {size} {orientation}: {title}") + metadata = TitleBlockMetadata( + title=title, + drawing_no=drawing_no, + part_no=part_no, + material=material, + scale=scale, + units=units, + drawn_by=drawn_by, + checked_by=checked_by, + date=date, + sheet=sheet, + revision=revision, + company=company, + ) + return await apply_titleblock( + _backend(ctx), + size=size, + metadata=metadata, + origin=(origin_x, origin_y), + orientation=orientation, + projection=projection or None, + frame=frame, + zones=zones, + marks=marks, + layout=layout or None, + ) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_sheet_frames.py b/tests/test_sheet_frames.py new file mode 100644 index 0000000..58faa2d --- /dev/null +++ b/tests/test_sheet_frames.py @@ -0,0 +1,268 @@ +"""ISO 5457 sheet frames: sizes, margins, the zone grid, centring and trim marks. + +The frame is the one thing every drawing this server makes carries, so a wrong +margin or a wrong division count is visible on all of them. Every number +asserted here is transcribed in `engineering/sheet/frames.py` with its clause. +""" + +from __future__ import annotations + +import pytest + +from engineering.sheet.frames import ( + CENTRING_OVERSHOOT, + EDGE_MARGIN, + FILING_MARGIN, + SHEETS, + TRIM_MARK_LONG, + TRIM_MARK_SHORT, + ZONE_DIVISIONS, + ZONE_MODULE, + frame_metrics, + sheet_size, + zone_divisions, +) + +EPS = 1e-9 + + +def test_sheet_sizes_are_the_iso_216_a_series(): + assert SHEETS == { + "A4": (210.0, 297.0), + "A3": (297.0, 420.0), + "A2": (420.0, 594.0), + "A1": (594.0, 841.0), + "A0": (841.0, 1189.0), + } + + +def test_sheet_sizes_agree_with_the_page_setup_table(): + """Two tables of one standard in one repository is one too many unless + they are pinned to each other.""" + from engineering.standards.papers import PAPER_SIZES + + for name, (w, h) in SHEETS.items(): + assert PAPER_SIZES[f"ISO_{name}"] == (int(w), int(h)) + + +def test_landscape_swaps_the_portrait_pair(): + assert sheet_size("A3", "landscape") == (420.0, 297.0) + assert sheet_size("A3", "portrait") == (297.0, 420.0) + assert sheet_size("a3") == (420.0, 297.0) + + +def test_an_unknown_sheet_is_refused_by_name_with_the_coverage(): + with pytest.raises(ValueError) as excinfo: + sheet_size("A5") + message = str(excinfo.value) + assert "A5" in message + for name in SHEETS: + assert name in message + + +def test_an_unknown_orientation_is_refused(): + with pytest.raises(ValueError) as excinfo: + sheet_size("A3", "diagonal") + assert "diagonal" in str(excinfo.value) + + +def test_margins_are_the_iso_5457_filing_20_and_edge_10(): + assert (FILING_MARGIN, EDGE_MARGIN) == (20.0, 10.0) + m = frame_metrics("A3") + assert m["margins"] == {"left": 20.0, "right": 10.0, "top": 10.0, "bottom": 10.0} + assert m["frame"] == [20.0, 10.0, 410.0, 287.0] + assert m["drawing_area"] == [390.0, 277.0] + assert m["centre"] == [210.0, 148.5] + + +def test_a4_portrait_drawing_area_is_exactly_the_titleblock_width(): + """ISO 7200's 180 mm title block has to fit the smallest sheet.""" + m = frame_metrics("A4", orientation="portrait") + assert m["drawing_area"][0] == pytest.approx(180.0, abs=EPS) + + +@pytest.mark.parametrize( + ("size", "orientation", "columns", "rows"), + [ + ("A4", "portrait", 4, 6), + ("A4", "landscape", 6, 4), + ("A3", "landscape", 8, 6), + ("A3", "portrait", 6, 8), + ("A2", "landscape", 12, 8), + ("A2", "portrait", 8, 12), + ("A1", "landscape", 16, 12), + ("A0", "landscape", 24, 16), + ("A0", "portrait", 16, 24), + ], +) +def test_zone_division_counts_match_the_authored_table(size, orientation, columns, rows): + z = frame_metrics(size, orientation=orientation)["zones"] + assert (z["columns"], z["rows"]) == (columns, rows) + long_side, short_side = ZONE_DIVISIONS[size] + if orientation == "landscape": + assert (z["columns"], z["rows"]) == (long_side, short_side) + else: + assert (z["columns"], z["rows"]) == (short_side, long_side) + + +def test_zone_divisions_follow_the_even_rule_over_50_mm(): + assert ZONE_MODULE == 50.0 + assert zone_divisions(390.0) == 8 # 7.80 -> 8 + assert zone_divisions(277.0) == 6 # 5.54 -> 6 + assert zone_divisions(564.0) == 12 # 11.28 -> 12 + assert zone_divisions(1159.0) == 24 # 23.18 -> 24 + assert zone_divisions(821.0) == 16 # 16.42 -> 16 + + +def test_zone_labels_cover_every_cell_and_letters_start_at_the_top(): + z = frame_metrics("A0")["zones"] + assert z["numbers"][0] == "1" and z["numbers"][-1] == "24" + assert len(z["numbers"]) == 24 + assert z["letters"][0] == "A" and z["letters"][-1] == "P" + assert len(z["letters"]) == 16 + assert z["column_width"] == pytest.approx(1159.0 / 24.0, abs=EPS) + assert z["row_height"] == pytest.approx(821.0 / 16.0, abs=EPS) + + +def test_the_mark_constants_are_the_standard_s(): + assert CENTRING_OVERSHOOT == 5.0 + assert (TRIM_MARK_LONG, TRIM_MARK_SHORT) == (10.0, 5.0) + + +# ── primitives ────────────────────────────────────────────────────────────── + +from engineering.mech.primitives import Line, Poly, Text # noqa: E402 +from engineering.sheet.frames import frame_prims # noqa: E402 + + +def _polys(prims): + return [p for p in prims if isinstance(p, Poly)] + + +def _lines(prims): + return [p for p in prims if isinstance(p, Line)] + + +def _texts(prims): + return [p for p in prims if isinstance(p, Text)] + + +def test_the_first_two_primitives_are_the_trimmed_sheet_and_the_frame(): + prims = frame_prims("A3", zones=False, marks=False) + assert len(prims) == 2 + sheet, frame = prims + assert isinstance(sheet, Poly) and sheet.closed and sheet.role == "visible" + assert sheet.points == ((0.0, 0.0), (420.0, 0.0), (420.0, 297.0), (0.0, 297.0)) + assert frame.points == ((20.0, 10.0), (410.0, 10.0), (410.0, 287.0), (20.0, 287.0)) + + +def test_the_zone_grid_has_a_tick_pair_per_interior_boundary_and_a_label_pair_per_cell(): + prims = frame_prims("A3", zones=True, marks=False) + # 7 interior column boundaries x 2 edges + 5 interior row boundaries x 2 edges + assert len(_lines(prims)) == (8 - 1) * 2 + (6 - 1) * 2 + # 8 columns x 2 edges + 6 rows x 2 edges + assert len(_texts(prims)) == 8 * 2 + 6 * 2 + assert {t.text for t in _texts(prims)} == { + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "A", + "B", + "C", + "D", + "E", + "F", + } + + +def test_zone_ticks_span_the_margin_band_only(): + prims = frame_prims("A3", zones=True, marks=False) + for line in [ln for ln in _lines(prims) if ln.p1[0] == ln.p2[0]]: + ys = sorted((line.p1[1], line.p2[1])) + assert ys in ([0.0, 10.0], [287.0, 297.0]), f"tick {line} leaves the margin band" + for line in [ln for ln in _lines(prims) if ln.p1[1] == ln.p2[1]]: + xs = sorted((line.p1[0], line.p2[0])) + assert xs in ([0.0, 20.0], [410.0, 420.0]), f"tick {line} leaves the margin band" + + +def test_the_first_zone_letter_is_at_the_top_of_the_sheet(): + prims = frame_prims("A3", zones=True, marks=False) + a_labels = [t for t in _texts(prims) if t.text == "A"] + f_labels = [t for t in _texts(prims) if t.text == "F"] + assert len(a_labels) == 2 and len(f_labels) == 2 + assert min(t.at[1] for t in a_labels) > max(t.at[1] for t in f_labels) + + +def test_centring_marks_run_from_the_trimmed_edge_to_5_mm_inside_the_frame(): + prims = frame_prims("A3", zones=False, marks=True) + centre_lines = [p for p in _lines(prims) if p.role == "center"] + assert len(centre_lines) == 4 + spans = {(tuple(c.p1), tuple(c.p2)) for c in centre_lines} + assert ((0.0, 148.5), (25.0, 148.5)) in spans + assert ((420.0, 148.5), (405.0, 148.5)) in spans + assert ((210.0, 0.0), (210.0, 15.0)) in spans + assert ((210.0, 297.0), (210.0, 282.0)) in spans + + +def test_each_corner_carries_two_overlapping_10_by_5_trim_rectangles(): + prims = frame_prims("A3", zones=False, marks=True) + rects = _polys(prims)[2:] # after the trimmed sheet and the frame + assert len(rects) == 8 + for rect in rects: + xs = [p[0] for p in rect.points] + ys = [p[1] for p in rect.points] + span = (abs(max(xs) - min(xs)), abs(max(ys) - min(ys))) + assert span in ((10.0, 5.0), (5.0, 10.0)), span + assert rect.closed + + +# ── drawing ───────────────────────────────────────────────────────────────── + +from engineering.sheet.frames import draw_sheet_frame # noqa: E402 + + +@pytest.mark.asyncio +async def test_draw_sheet_frame_puts_the_border_on_titleblock_and_the_zones_on_text(backend): + result = await draw_sheet_frame(backend, "A3", zones=True, marks=True) + assert result["ok"] is True + assert result["size"] == "A3" + assert result["bbox"] == {"min": [0.0, 0.0], "max": [420.0, 297.0]} + outer = await backend.entity_get(result["outer_border"]) + inner = await backend.entity_get(result["inner_border"]) + assert outer.layer == "TITLEBLOCK" and inner.layer == "TITLEBLOCK" + texts = [ + e for e in await backend.entity_list(type_filter="TEXT") if e.properties["text"] == "A" + ] + assert texts and all(e.layer == "TEXT" for e in texts) + + +@pytest.mark.asyncio +async def test_draw_sheet_frame_offsets_every_primitive_by_the_origin(backend): + result = await draw_sheet_frame( + backend, "A4", orientation="portrait", zones=False, marks=False, origin=(100.0, 50.0) + ) + assert result["bbox"] == {"min": [100.0, 50.0], "max": [310.0, 347.0]} + assert len(result["handles"]) == 2 + + +@pytest.mark.asyncio +async def test_a_bad_size_is_refused_before_anything_is_drawn(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError): + await draw_sheet_frame(backend, "A5") + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_a_missing_layout_is_refused_before_anything_is_drawn(backend): + before = len(await backend.entity_list()) + with pytest.raises(RuntimeError) as excinfo: + await draw_sheet_frame(backend, "A3", layout="NoSuchSheet") + assert "NoSuchSheet" in str(excinfo.value) + assert "layout_create" in str(excinfo.value) + assert len(await backend.entity_list()) == before diff --git a/tests/test_sheet_titleblock.py b/tests/test_sheet_titleblock.py new file mode 100644 index 0000000..47e2929 --- /dev/null +++ b/tests/test_sheet_titleblock.py @@ -0,0 +1,190 @@ +"""ISO 7200 title blocks for A4-A0, and the A3 geometry that must not move.""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import Circle, Line, Poly, Text +from engineering.sheet.titleblock import ( + TB_HEIGHT, + TB_WIDTH, + TITLE_TEXT_INDEX, + TITLEBLOCK_FIELDS, + TITLEBLOCK_RULE_COUNT, + VALUE_FIELDS, + TitleBlockMetadata, + apply_titleblock, + projection_symbol_prims, + titleblock_origin, + titleblock_prims, + value_text_indices, +) + +EPS = 1e-9 + + +def _meta(**overrides) -> TitleBlockMetadata: + base = dict( + title="HELICAL GEAR", + drawing_no="AM-2026-001", + part_no="GR-24T-M3", + material="C45", + scale="1:1", + units="mm", + drawn_by="Umutcan", + checked_by="QA", + date="2026-04-28", + sheet="1/1", + revision="A", + company="Anka-Makine", + ) + base.update(overrides) + return TitleBlockMetadata(**base) + + +def test_the_block_is_the_iso_7200_180_mm_wide_and_sits_in_the_frame_corner(): + assert TB_WIDTH == 180.0 and TB_HEIGHT == 60.0 + assert titleblock_origin("A3") == (230.0, 10.0) + assert titleblock_origin("A4", orientation="portrait") == (20.0, 10.0) + assert titleblock_origin("A0") == (999.0, 10.0) + + +def test_every_declared_field_is_a_metadata_attribute(): + meta = _meta() + for field in TITLEBLOCK_FIELDS: + assert hasattr(meta, field), field + + +def test_the_a3_rules_are_exactly_where_they_have_always_been(): + prims = titleblock_prims("A3", _meta(), projection=None) + rules = prims[:TITLEBLOCK_RULE_COUNT] + assert isinstance(rules[0], Poly) and rules[0].closed + assert rules[0].points == ((230.0, 10.0), (410.0, 10.0), (410.0, 70.0), (230.0, 70.0)) + assert [(r.p1[1], r.p1[0], r.p2[0]) for r in rules[1:4]] == [ + (20.0, 230.0, 410.0), + (35.0, 230.0, 410.0), + (50.0, 230.0, 410.0), + ] + assert [(r.p1[0], r.p1[1], r.p2[1]) for r in rules[4:]] == [ + (320.0, 35.0, 50.0), + (365.0, 35.0, 50.0), + (290.0, 20.0, 35.0), + (350.0, 20.0, 35.0), + (275.0, 10.0, 20.0), + (320.0, 10.0, 20.0), + (365.0, 10.0, 20.0), + ] + + +def test_the_a3_value_texts_are_exactly_where_they_have_always_been(): + prims = titleblock_prims("A3", _meta(), projection=None) + index = value_text_indices() + assert prims[index["drawing_no"]] == Text(at=(233.0, 40.0), text="AM-2026-001", height=3.5) + assert prims[index["revision"]] == Text(at=(323.0, 40.0), text="A", height=3.5) + assert prims[index["sheet"]] == Text(at=(368.0, 40.0), text="1/1", height=3.5) + assert prims[index["part_no"]] == Text(at=(233.0, 25.0), text="GR-24T-M3", height=3.5) + assert prims[index["material"]] == Text(at=(293.0, 25.0), text="C45", height=3.5) + assert prims[index["scale"]] == Text(at=(353.0, 25.0), text="1:1", height=3.5) + assert prims[index["drawn_by"]] == Text(at=(233.0, 11.5), text="Umutcan", height=3.5) + assert prims[index["checked_by"]] == Text(at=(278.0, 11.5), text="QA", height=3.5) + assert prims[index["date"]] == Text(at=(323.0, 11.5), text="2026-04-28", height=3.5) + assert prims[index["company"]] == Text(at=(368.0, 11.5), text="Anka-Makine", height=3.5) + + +def test_the_title_sits_at_the_pinned_index_and_carries_the_text_verbatim(): + prims = titleblock_prims("A3", _meta(title="SPUR GEAR / M3 Z24"), projection=None) + title = prims[TITLE_TEXT_INDEX] + assert isinstance(title, Text) + assert title.text == "SPUR GEAR / M3 Z24" + assert title.height == 5.0 + assert title.at[1] == pytest.approx(57.5, abs=EPS) + + +def test_projection_none_draws_no_symbol_and_first_draws_one(): + plain = titleblock_prims("A3", _meta(), projection=None) + first = titleblock_prims("A3", _meta(), projection="first") + assert len(plain) == TITLE_TEXT_INDEX + 1 + 2 * len(VALUE_FIELDS) + assert len(first) == len(plain) + 4 + + +def test_first_angle_puts_the_circles_to_the_right_of_the_cone(): + first = projection_symbol_prims((0.0, 0.0), "first") + third = projection_symbol_prims((0.0, 0.0), "third") + cone_first = next(p for p in first if isinstance(p, Poly)) + circles_first = [p for p in first if isinstance(p, Circle)] + assert max(x for x, _ in cone_first.points) < min(c.center[0] for c in circles_first) + cone_third = next(p for p in third if isinstance(p, Poly)) + circles_third = [p for p in third if isinstance(p, Circle)] + assert min(x for x, _ in cone_third.points) > max(c.center[0] for c in circles_third) + assert {round(c.radius, 6) for c in circles_first} == {4.0, 2.0} + assert any(isinstance(p, Line) and p.role == "center" for p in first) + + +def test_the_cone_s_small_end_faces_the_circles_side_in_first_angle(): + """The derivation only holds if the view drawn as circles really is the + view from the cone's small end.""" + cone = next(p for p in projection_symbol_prims((0.0, 0.0), "first") if isinstance(p, Poly)) + left_x = min(x for x, _ in cone.points) + right_x = max(x for x, _ in cone.points) + left_height = max(y for x, y in cone.points if x == left_x) * 2 + right_height = max(y for x, y in cone.points if x == right_x) * 2 + assert left_height == pytest.approx(4.0, abs=EPS) + assert right_height == pytest.approx(8.0, abs=EPS) + + +def test_an_unknown_projection_angle_is_refused(): + with pytest.raises(ValueError) as excinfo: + titleblock_prims("A3", _meta(), projection="second") + assert "second" in str(excinfo.value) + + +def test_a_sheet_narrower_than_the_title_block_is_refused_by_name(): + """There is no such size in A4-A0; the guard exists so a future size cannot + ship a title block hanging off the paper.""" + from engineering.sheet import frames + + original = dict(frames.SHEETS) + frames.SHEETS["A9"] = (100.0, 140.0) + try: + with pytest.raises(ValueError) as excinfo: + titleblock_origin("A9", orientation="portrait") + assert "180" in str(excinfo.value) + finally: + frames.SHEETS.clear() + frames.SHEETS.update(original) + + +@pytest.mark.asyncio +async def test_apply_titleblock_returns_the_payload_the_a3_tool_always_returned(backend): + result = await apply_titleblock(backend, size="A3", metadata=_meta(), projection=None) + assert result["bbox"] == {"min": [0.0, 0.0], "max": [420.0, 297.0]} + assert set(result["value_texts"]) == { + "drawing_no", + "revision", + "sheet", + "part_no", + "material", + "scale", + "drawn_by", + "checked_by", + "date", + "company", + } + assert len(result["titleblock_lines"]) == TITLEBLOCK_RULE_COUNT + title = await backend.entity_get(result["title_text"]) + assert title.type == "TEXT" and title.properties["text"] == "HELICAL GEAR" + assert title.layer == "TEXT" + for handle in result["titleblock_lines"]: + assert (await backend.entity_get(handle)).layer == "TITLEBLOCK" + assert result["projection_symbol"] == [] + + +@pytest.mark.asyncio +async def test_apply_titleblock_draws_every_size_with_its_symbol(backend): + for size in ("A4", "A3", "A2", "A1", "A0"): + orientation = "portrait" if size == "A4" else "landscape" + result = await apply_titleblock( + backend, size=size, metadata=_meta(), orientation=orientation + ) + assert result["ok"] is True and result["size"] == size + assert len(result["projection_symbol"]) == 4 diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 4b55428..ebe17b7 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (204 tools, 23 groups). + """Frozen snapshot of the surface (206 tools, 23 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and @@ -527,7 +527,14 @@ async def test_tool_group_sizes_are_unchanged(): merge task tagged them, the four groups had filed those 28 tools under `layouts` / `templates` / `drawing` / `layers` / `view` / `system` by their secondary tags; those counts returned to their pre-track-E values - when the section tags landed.) Every other number here has been unchanged + when the section tags landed.) + `engineering` moved 10 -> 12 when v1.6's SECTION 24 opened with + `sheet_frame` and `titleblock_apply` (the ISO 5457 frame and the ISO 7200 + title block for every size A4-A0). Both are tagged `engineering` and + `sheet`, and `sheet` is not in `_GROUP_TAG_PRIORITY`, so they file under + `engineering` beside `titleblock_apply_iso_a3` rather than opening a + 24th group. + Every other number here has been unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} @@ -538,7 +545,7 @@ async def test_tool_group_sizes_are_unchanged(): "corner_ops": 4, "dimensions": 5, "drawing": 11, - "engineering": 10, + "engineering": 12, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, @@ -556,4 +563,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 204 + assert sum(sizes.values()) == 206 From e57ae8b3ac12bfbfb2f18d1ba4d9da3e5fd20fdd Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:49:28 +0300 Subject: [PATCH 2/7] fix(task-12): restore the caller's tab from the layout listing; source the projection symbol enter_layout read the caller's space from `backend._current_space`, which only EzdxfBackend has. On ComBackend the getattr fell through to "Model", so `sheet_frame` / `titleblock_apply` with a `layout=` dropped a live caller onto the Model tab instead of putting them back -- the opposite of what the docstring and both tool docs promise, and a dual-engine parity break in new shared infrastructure. Measured on the live seat (AutoCAD 2026, scratch document from drawing_new(), closed with discard): scratch document: Drawing24.dwg layouts: ['Layout1', 'Layout2', 'Model'] B) current BEFORE: Layout2 B) the value the OLD code read (backend._current_space): < getattr default 'Model'>> `layout_list()` already reported the right value two lines earlier on both engines (ezdxf from $TILEMODE / the active layout, COM from `doc.ActiveLayout.Name` -- read live as "Layout2" above), so `previous` now comes from `listing["current"]`. The projection-angle symbol was justified by an in-code derivation that cannot decide the question, and the test restated the implementation. The derivation was also applied inconsistently: the third-angle symbol mirrored the cone as well as the end view, leaving the trapezoid's short side pointing AWAY from the circles -- which is the first-angle arrangement. Transcribed instead from two independent reproductions of the ISO 5456-2 figure, both downloaded and measured (ISO's own figure is paywalled and was NOT read; the module says so): * FreeCAD's ISO 5457 sheet template A3_Landscape_ISO5457_advanced.svg, `first_angle_trapezoid` = "m 389,222 -10,2.5 v 5 l 10,2.5 z" -> 5-unit side at x=379, 10-unit side at x=389; circles r2.5/r5 at cx=396. * Wikimedia Commons "Convention placement vues dessin technique.svg", both symbols side by side, labelled FR and US: FR 35.856 @ x=13.343 / 66.135 @ x=81.869, circles x=148.005; US 35.856 @ x=380.275 / 66.135 @ x=448.801, circles x=314.140. Both draw the trapezoid the same way -- short side left -- and move only the end view: first angle puts it right of the cone, third angle left. Equivalently the short side points away from the circles in first angle and towards them in third, which is how every source states it in words. `third` now follows. Tests: the reference figures are transcribed into the test as data, and the symbol is asserted to land its three landmarks in the order each figure does, so the test cannot pass by agreeing with the code. The layout restore is pinned through a proxy that hides `_current_space` from the real backend rather than a fake that owns members ComBackend does not have. Both new tests fail on the previous code (measured: "assert 16.0 < 8.0" for the mirrored cone; "- SheetB + Model" for the tab). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/sheet/frames.py | 7 +- engineering/sheet/titleblock.py | 68 +++++++++++++------ tests/conftest.py | 28 ++++++++ tests/test_sheet_frames.py | 33 ++++++++++ tests/test_sheet_titleblock.py | 112 ++++++++++++++++++++++++++------ 5 files changed, 209 insertions(+), 39 deletions(-) diff --git a/engineering/sheet/frames.py b/engineering/sheet/frames.py index 0e8dffb..e82b977 100644 --- a/engineering/sheet/frames.py +++ b/engineering/sheet/frames.py @@ -250,7 +250,12 @@ async def enter_layout(backend: AutoCADBackend, layout: str | None, *, caller: s f"{caller}: layout {layout!r} does not exist " f"(have: {', '.join(sorted(names))}). Create it with layout_create first." ) - previous = getattr(backend, "_current_space", "Model") + # The caller's tab comes from the listing that was just read -- both engines + # report it there (ezdxf from $TILEMODE / the active layout, COM from + # ``doc.ActiveLayout.Name``). A private attribute would not: only + # EzdxfBackend has ``_current_space``, so reading it left a live caller + # standing on Model after every border. + previous = str(listing.get("current") or "Model") switched = await backend.layout_set_current(layout) if not switched.get("ok", True): raise RuntimeError(f"{caller}: {switched.get('error')}") diff --git a/engineering/sheet/titleblock.py b/engineering/sheet/titleblock.py index 8e662d5..1f9e384 100644 --- a/engineering/sheet/titleblock.py +++ b/engineering/sheet/titleblock.py @@ -15,9 +15,10 @@ Data fields ISO 7200:2004 -- the mandatory fields are the legal owner shop draws (`part_no`, `material`, `scale`, `units`). ISO 7200's full list of optional fields is NOT transcribed -- only the fields this block has a cell for. -Projection angle ISO 128-30 / ISO 5456-2 -- the truncated-cone symbol; see - `projection_symbol_prims` for the arrangement and where it - comes from. +Projection angle The truncated-cone symbol of ISO 5456-2 / ISO 128-30. ISO's + own figure is paywalled and was NOT read here; the arrangement + is transcribed from reproductions of it, measured, and named + in `projection_symbol_prims`. The row heights (20 / 15 / 15 / 10 mm) and the cell splits are this repository's layout, not ISO 7200 values: the standard fixes the *data*, not @@ -160,34 +161,63 @@ def titleblock_origin(size: str, *, orientation: str = "landscape") -> Pt: def projection_symbol_prims(at: Pt, angle: str = "first") -> tuple[Prim, ...]: - """The ISO 128-30 projection-angle symbol, centred on `at`. + """The ISO 5456-2 / ISO 128-30 projection-angle symbol, centred on `at`. - Arrangement, and where it comes from. The symbol is a truncated cone with a - horizontal axis and its small end facing left. The first- and third-angle - symbols are mirror images about a vertical axis, and which is which follows - from the projection rule itself: first-angle projection puts the view seen - from the left ON THE RIGHT, and the view of this cone seen from the left is - the small circle inside the large one. So FIRST angle draws the cone on the - left and the concentric circles on the right; THIRD angle is its mirror. + ARRANGEMENT -- transcribed, not derived. The symbol is a truncated cone + drawn in two views: the cone in elevation (a trapezoid) and the end view + (two concentric circles). Which side the end view sits on is what makes the + symbol mean *first* or *third* angle, and it is a convention that has to be + read off the standard's figure: the projection rule alone cannot pick it, + because the same picture is a legal drawing of the mirrored object seen + from the other side. + + ISO 5456-2's own figure is behind ISO's paywall and was not read. Two + independent reproductions of it were downloaded and measured instead, and + they agree on every landmark used here: + + * FreeCAD's ISO 5457 sheet template, first-angle symbol + (`src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg`, + ids `first_angle_trapezoid` / `first_angle_*_circle`): the trapezoid is + `m 389,222 -10,2.5 v 5 l 10,2.5 z` -- a 5-unit side at x = 379 and a + 10-unit side at x = 389 -- and the circles (r 2.5 and r 5) are at + cx = 396. Short side LEFT; end view to the RIGHT of the cone. + * Wikimedia Commons `Convention placement vues dessin technique.svg`, which + draws both symbols side by side, labelled FR and US. FR (first angle): + trapezoid 35.856 units tall at x = 13.343 and 66.135 at x = 81.869, + circles at x = 148.005. US (third angle): trapezoid 35.856 at + x = 380.275 and 66.135 at x = 448.801, circles at x = 314.140. + + So, in both symbols alike, the trapezoid's SHORT side is on the left; only + the end view changes side: + + first angle cone, then the circles to its RIGHT + third angle the circles, then the cone to their RIGHT + + which is the same thing as the rule the standard is usually quoted by -- + the short side points AWAY from the circles in first angle and TOWARDS + them in third. `tests/test_sheet_titleblock.py` pins the ordering against + the measured figures above, so this cannot be "fixed" back by eye. The proportions (large circle diameter 8, small 4, cone 8 mm long) are this - module's; ISO 5456-2's proportion table is not transcribed. The arrangement - is the part that can be *wrong* on a drawing, and it is derived above. + module's; ISO 5456-2's proportion table is not transcribed. They keep the + 1:2 small-to-large ratio both reference figures' circles have. """ if angle not in PROJECTION_ANGLES: raise ValueError(f"projection angle must be one of {PROJECTION_ANGLES}, got {angle!r}") cx, cy = float(at[0]), float(at[1]) half = (PROJECTION_CONE_LENGTH + PROJECTION_GAP + 2 * PROJECTION_R) / 2.0 + # The end view sits right of the cone in first angle, left of it in third. sign = 1.0 if angle == "first" else -1.0 - cone_far = -half * sign - cone_near = cone_far + sign * PROJECTION_CONE_LENGTH circles_x = (half - PROJECTION_R) * sign + # The trapezoid itself is the same picture in both symbols: short side left. + cone_left = -half if angle == "first" else half - PROJECTION_CONE_LENGTH + cone_right = cone_left + PROJECTION_CONE_LENGTH cone = Poly( points=( - (cx + cone_far, cy - PROJECTION_SMALL_R), - (cx + cone_near, cy - PROJECTION_R), - (cx + cone_near, cy + PROJECTION_R), - (cx + cone_far, cy + PROJECTION_SMALL_R), + (cx + cone_left, cy - PROJECTION_SMALL_R), + (cx + cone_right, cy - PROJECTION_R), + (cx + cone_right, cy + PROJECTION_R), + (cx + cone_left, cy + PROJECTION_SMALL_R), ), closed=True, ) diff --git a/tests/conftest.py b/tests/conftest.py index 7a18051..b97ad6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,3 +40,31 @@ async def backend(): await b.drawing_new() yield b await b.disconnect() + + +class WithoutPrivateState: + """The real backend with its ezdxf-only private attributes taken away. + + ``EzdxfBackend`` tracks the current tab in ``self._current_space``; + ``ComBackend`` does not -- the live tab lives in AutoCAD + (``doc.ActiveLayout``), so there is nothing of that name on it. Shared code + that reaches for the private attribute therefore silently takes the + ``getattr`` default on every live seat, and no ezdxf-backed test can see + it. Wrapping the real backend (rather than writing a fake that owns members + the real object never had) keeps every other call honest. + """ + + def __init__(self, inner, hide=("_current_space",)): + object.__setattr__(self, "_inner", inner) + object.__setattr__(self, "_hidden", frozenset(hide)) + + def __getattr__(self, name): + if name in self._hidden: + raise AttributeError(f"{type(self._inner).__name__} has no attribute {name!r}") + return getattr(self._inner, name) + + +@pytest_asyncio.fixture +async def backend_without_private_state(backend): + """`backend`, minus the private attributes only the headless engine has.""" + return WithoutPrivateState(backend) diff --git a/tests/test_sheet_frames.py b/tests/test_sheet_frames.py index 58faa2d..94b6d40 100644 --- a/tests/test_sheet_frames.py +++ b/tests/test_sheet_frames.py @@ -266,3 +266,36 @@ async def test_a_missing_layout_is_refused_before_anything_is_drawn(backend): assert "NoSuchSheet" in str(excinfo.value) assert "layout_create" in str(excinfo.value) assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_the_frame_puts_the_caller_back_on_the_tab_it_found_them_on(backend): + await backend.layout_create("SheetA") + await backend.layout_create("SheetB") + await backend.layout_set_current("SheetB") + await draw_sheet_frame(backend, "A3", zones=False, marks=False, layout="SheetA") + assert (await backend.layout_list())["current"] == "SheetB" + + +@pytest.mark.asyncio +async def test_the_caller_s_tab_is_read_from_the_listing_not_a_private_attribute( + backend, backend_without_private_state +): + """`_current_space` exists on EzdxfBackend only. ComBackend keeps the live + tab in AutoCAD, so reading the private attribute there fell through to the + ``getattr`` default and dropped a live caller onto Model after every + border -- measured on AutoCAD 2026. `layout_list()["current"]` is the value + both engines really report, and `enter_layout` already had it in hand. + """ + await backend.layout_create("SheetA") + await backend.layout_create("SheetB") + await backend.layout_set_current("SheetB") + with pytest.raises(AttributeError): + _ = backend_without_private_state._current_space + + result = await draw_sheet_frame( + backend_without_private_state, "A3", zones=False, marks=False, layout="SheetA" + ) + + assert result["layout"] == "SheetA" + assert (await backend.layout_list())["current"] == "SheetB" diff --git a/tests/test_sheet_titleblock.py b/tests/test_sheet_titleblock.py index 47e2929..f067192 100644 --- a/tests/test_sheet_titleblock.py +++ b/tests/test_sheet_titleblock.py @@ -107,29 +107,87 @@ def test_projection_none_draws_no_symbol_and_first_draws_one(): assert len(first) == len(plain) + 4 -def test_first_angle_puts_the_circles_to_the_right_of_the_cone(): +#: The ISO 5456-2 / ISO 128-30 projection symbol, transcribed from two +#: independent reproductions of the standard's figure (ISO's own is paywalled +#: and was not read). Each entry is that source's OWN coordinates, measured off +#: the file, and the landmark order left-to-right is what the symbol means. +#: +#: 1. FreeCAD's ISO 5457 sheet template, first-angle symbol -- +#: src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg, +#: ids `first_angle_trapezoid`, `first_angle_inner circle`, +#: `first_angle_outer circle`. +#: 2. Wikimedia Commons `Convention placement vues dessin technique.svg`, which +#: draws both symbols side by side, labelled FR (first) and US (third). +#: +#: A test that read these numbers out of `projection_symbol_prims` instead +#: would pass whichever way round the symbol was drawn, which is exactly how +#: the mirrored third-angle cone shipped in the first place. +REFERENCE_FIGURES = { + "first": ( + # source, short-side x, long-side x, end-view (circles) x + ("FreeCAD A3_Landscape_ISO5457_advanced.svg", 379.0, 389.0, 396.0), + ("Commons Convention placement vues dessin technique.svg (FR)", 13.343, 81.869, 148.005), + ), + "third": ( + ("Commons Convention placement vues dessin technique.svg (US)", 380.275, 448.801, 314.140), + ), +} + + +#: The three landmarks, in the order `_landmarks` and REFERENCE_FIGURES list them. +LANDMARKS = ("short", "long", "circles") + + +def _landmarks(angle: str) -> tuple[float, float, float]: + """(short-side x, long-side x, end-view x) of the symbol this module draws.""" + prims = projection_symbol_prims((0.0, 0.0), angle) + cone = next(p for p in prims if isinstance(p, Poly)) + circles = [p for p in prims if isinstance(p, Circle)] + left_x = min(x for x, _ in cone.points) + right_x = max(x for x, _ in cone.points) + left_h = max(y for x, y in cone.points if x == left_x) * 2 + right_h = max(y for x, y in cone.points if x == right_x) * 2 + short_x, long_x = (left_x, right_x) if left_h < right_h else (right_x, left_x) + assert len({c.center[0] for c in circles}) == 1 + return short_x, long_x, circles[0].center[0] + + +@pytest.mark.parametrize("angle", ["first", "third"]) +def test_the_symbol_lands_the_landmarks_in_the_order_the_reference_figures_do(angle): + """The meaning of the symbol is the left-to-right order of three landmarks: + the cone's short side, its long side and the end view. Assert that order + against the measured figures, not against what the module computes.""" + mine = _landmarks(angle) + for source, *reference in REFERENCE_FIGURES[angle]: + expected = [name for _x, name in sorted(zip(reference, LANDMARKS, strict=True))] + got = [name for _x, name in sorted(zip(mine, LANDMARKS, strict=True))] + assert got == expected, f"{angle}-angle symbol disagrees with {source}" + + +def test_the_short_side_points_away_from_the_circles_in_first_and_towards_in_third(): + """The mirror-invariant half of the rule, and the one every source states + in words: Wikipedia's Multiview orthographic projection, Symbol -- + "the first-angle symbol shows the trapezoid with its shortest side away + from the circles", "the third-angle symbol shows the trapezoid with its + shortest side towards the circles".""" + short_x, long_x, circles_x = _landmarks("first") + assert abs(short_x - circles_x) > abs(long_x - circles_x) + short_x, long_x, circles_x = _landmarks("third") + assert abs(short_x - circles_x) < abs(long_x - circles_x) + + +def test_the_symbol_keeps_the_reference_figures_proportions_and_its_axis(): + """Both reference figures draw the end view as two circles of ratio 1:2 + (FreeCAD r 2.5 / r 5) on a centre line; the absolute size is this + module's.""" first = projection_symbol_prims((0.0, 0.0), "first") - third = projection_symbol_prims((0.0, 0.0), "third") - cone_first = next(p for p in first if isinstance(p, Poly)) - circles_first = [p for p in first if isinstance(p, Circle)] - assert max(x for x, _ in cone_first.points) < min(c.center[0] for c in circles_first) - cone_third = next(p for p in third if isinstance(p, Poly)) - circles_third = [p for p in third if isinstance(p, Circle)] - assert min(x for x, _ in cone_third.points) > max(c.center[0] for c in circles_third) - assert {round(c.radius, 6) for c in circles_first} == {4.0, 2.0} + circles = [p for p in first if isinstance(p, Circle)] + assert {round(c.radius, 6) for c in circles} == {4.0, 2.0} assert any(isinstance(p, Line) and p.role == "center" for p in first) -def test_the_cone_s_small_end_faces_the_circles_side_in_first_angle(): - """The derivation only holds if the view drawn as circles really is the - view from the cone's small end.""" - cone = next(p for p in projection_symbol_prims((0.0, 0.0), "first") if isinstance(p, Poly)) - left_x = min(x for x, _ in cone.points) - right_x = max(x for x, _ in cone.points) - left_height = max(y for x, y in cone.points if x == left_x) * 2 - right_height = max(y for x, y in cone.points if x == right_x) * 2 - assert left_height == pytest.approx(4.0, abs=EPS) - assert right_height == pytest.approx(8.0, abs=EPS) +def test_the_two_symbols_are_not_the_same_picture(): + assert _landmarks("first") != _landmarks("third") def test_an_unknown_projection_angle_is_refused(): @@ -188,3 +246,19 @@ async def test_apply_titleblock_draws_every_size_with_its_symbol(backend): ) assert result["ok"] is True and result["size"] == size assert len(result["projection_symbol"]) == 4 + + +@pytest.mark.asyncio +async def test_the_titleblock_puts_the_caller_back_on_the_tab_it_found_them_on( + backend, backend_without_private_state +): + """`apply_titleblock` routes through the same `enter_layout`, so the live + engine dropped the caller onto Model here too.""" + await backend.layout_create("SheetA") + await backend.layout_create("SheetB") + await backend.layout_set_current("SheetB") + result = await apply_titleblock( + backend_without_private_state, size="A3", metadata=_meta(), layout="SheetA" + ) + assert result["layout"] == "SheetA" + assert (await backend.layout_list())["current"] == "SheetB" From ffa8327770d9c9dfc0abe44506797c92563a6239 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:51:00 +0300 Subject: [PATCH 3/7] fix(task-12): keep the plan's task-12 body in step with the shipped fix The plan embeds Task 12's module and tests verbatim, so it still carried the `_current_space` read and the derivation that put the third-angle cone's short side away from the circles. Both replaced with what shipped, so a re-run of the task cannot transcribe the defects back. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- .../2026-09-22-v1.6-mech-implementation.md | 164 ++++++++++++++---- 1 file changed, 128 insertions(+), 36 deletions(-) diff --git a/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md b/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md index 8a883df..d3cdaa3 100644 --- a/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md +++ b/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md @@ -16288,7 +16288,12 @@ async def enter_layout(backend: AutoCADBackend, layout: str | None, *, caller: s f"{caller}: layout {layout!r} does not exist " f"(have: {', '.join(sorted(names))}). Create it with layout_create first." ) - previous = getattr(backend, "_current_space", "Model") + # The caller's tab comes from the listing that was just read -- both engines + # report it there (ezdxf from $TILEMODE / the active layout, COM from + # ``doc.ActiveLayout.Name``). A private attribute would not: only + # EzdxfBackend has ``_current_space``, so reading it left a live caller + # standing on Model after every border. + previous = str(listing.get("current") or "Model") switched = await backend.layout_set_current(layout) if not switched.get("ok", True): raise RuntimeError(f"{caller}: {switched.get('error')}") @@ -16502,29 +16507,87 @@ def test_projection_none_draws_no_symbol_and_first_draws_one(): assert len(first) == len(plain) + 4 -def test_first_angle_puts_the_circles_to_the_right_of_the_cone(): +#: The ISO 5456-2 / ISO 128-30 projection symbol, transcribed from two +#: independent reproductions of the standard's figure (ISO's own is paywalled +#: and was not read). Each entry is that source's OWN coordinates, measured off +#: the file, and the landmark order left-to-right is what the symbol means. +#: +#: 1. FreeCAD's ISO 5457 sheet template, first-angle symbol -- +#: src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg, +#: ids `first_angle_trapezoid`, `first_angle_inner circle`, +#: `first_angle_outer circle`. +#: 2. Wikimedia Commons `Convention placement vues dessin technique.svg`, which +#: draws both symbols side by side, labelled FR (first) and US (third). +#: +#: A test that read these numbers out of `projection_symbol_prims` instead +#: would pass whichever way round the symbol was drawn, which is exactly how +#: the mirrored third-angle cone shipped in the first place. +REFERENCE_FIGURES = { + "first": ( + # source, short-side x, long-side x, end-view (circles) x + ("FreeCAD A3_Landscape_ISO5457_advanced.svg", 379.0, 389.0, 396.0), + ("Commons Convention placement vues dessin technique.svg (FR)", 13.343, 81.869, 148.005), + ), + "third": ( + ("Commons Convention placement vues dessin technique.svg (US)", 380.275, 448.801, 314.140), + ), +} + + +#: The three landmarks, in the order `_landmarks` and REFERENCE_FIGURES list them. +LANDMARKS = ("short", "long", "circles") + + +def _landmarks(angle: str) -> tuple[float, float, float]: + """(short-side x, long-side x, end-view x) of the symbol this module draws.""" + prims = projection_symbol_prims((0.0, 0.0), angle) + cone = next(p for p in prims if isinstance(p, Poly)) + circles = [p for p in prims if isinstance(p, Circle)] + left_x = min(x for x, _ in cone.points) + right_x = max(x for x, _ in cone.points) + left_h = max(y for x, y in cone.points if x == left_x) * 2 + right_h = max(y for x, y in cone.points if x == right_x) * 2 + short_x, long_x = (left_x, right_x) if left_h < right_h else (right_x, left_x) + assert len({c.center[0] for c in circles}) == 1 + return short_x, long_x, circles[0].center[0] + + +@pytest.mark.parametrize("angle", ["first", "third"]) +def test_the_symbol_lands_the_landmarks_in_the_order_the_reference_figures_do(angle): + """The meaning of the symbol is the left-to-right order of three landmarks: + the cone's short side, its long side and the end view. Assert that order + against the measured figures, not against what the module computes.""" + mine = _landmarks(angle) + for source, *reference in REFERENCE_FIGURES[angle]: + expected = [name for _x, name in sorted(zip(reference, LANDMARKS, strict=True))] + got = [name for _x, name in sorted(zip(mine, LANDMARKS, strict=True))] + assert got == expected, f"{angle}-angle symbol disagrees with {source}" + + +def test_the_short_side_points_away_from_the_circles_in_first_and_towards_in_third(): + """The mirror-invariant half of the rule, and the one every source states + in words: Wikipedia's Multiview orthographic projection, Symbol -- + "the first-angle symbol shows the trapezoid with its shortest side away + from the circles", "the third-angle symbol shows the trapezoid with its + shortest side towards the circles".""" + short_x, long_x, circles_x = _landmarks("first") + assert abs(short_x - circles_x) > abs(long_x - circles_x) + short_x, long_x, circles_x = _landmarks("third") + assert abs(short_x - circles_x) < abs(long_x - circles_x) + + +def test_the_symbol_keeps_the_reference_figures_proportions_and_its_axis(): + """Both reference figures draw the end view as two circles of ratio 1:2 + (FreeCAD r 2.5 / r 5) on a centre line; the absolute size is this + module's.""" first = projection_symbol_prims((0.0, 0.0), "first") - third = projection_symbol_prims((0.0, 0.0), "third") - cone_first = next(p for p in first if isinstance(p, Poly)) - circles_first = [p for p in first if isinstance(p, Circle)] - assert max(x for x, _ in cone_first.points) < min(c.center[0] for c in circles_first) - cone_third = next(p for p in third if isinstance(p, Poly)) - circles_third = [p for p in third if isinstance(p, Circle)] - assert min(x for x, _ in cone_third.points) > max(c.center[0] for c in circles_third) - assert {round(c.radius, 6) for c in circles_first} == {4.0, 2.0} + circles = [p for p in first if isinstance(p, Circle)] + assert {round(c.radius, 6) for c in circles} == {4.0, 2.0} assert any(isinstance(p, Line) and p.role == "center" for p in first) -def test_the_cone_s_small_end_faces_the_circles_side_in_first_angle(): - """The derivation only holds if the view drawn as circles really is the - view from the cone's small end.""" - cone = next(p for p in projection_symbol_prims((0.0, 0.0), "first") if isinstance(p, Poly)) - left_x = min(x for x, _ in cone.points) - right_x = max(x for x, _ in cone.points) - left_height = max(y for x, y in cone.points if x == left_x) * 2 - right_height = max(y for x, y in cone.points if x == right_x) * 2 - assert left_height == pytest.approx(4.0, abs=EPS) - assert right_height == pytest.approx(8.0, abs=EPS) +def test_the_two_symbols_are_not_the_same_picture(): + assert _landmarks("first") != _landmarks("third") def test_an_unknown_projection_angle_is_refused(): @@ -16757,34 +16820,63 @@ def titleblock_origin(size: str, *, orientation: str = "landscape") -> Pt: def projection_symbol_prims(at: Pt, angle: str = "first") -> tuple[Prim, ...]: - """The ISO 128-30 projection-angle symbol, centred on `at`. + """The ISO 5456-2 / ISO 128-30 projection-angle symbol, centred on `at`. - Arrangement, and where it comes from. The symbol is a truncated cone with a - horizontal axis and its small end facing left. The first- and third-angle - symbols are mirror images about a vertical axis, and which is which follows - from the projection rule itself: first-angle projection puts the view seen - from the left ON THE RIGHT, and the view of this cone seen from the left is - the small circle inside the large one. So FIRST angle draws the cone on the - left and the concentric circles on the right; THIRD angle is its mirror. + ARRANGEMENT -- transcribed, not derived. The symbol is a truncated cone + drawn in two views: the cone in elevation (a trapezoid) and the end view + (two concentric circles). Which side the end view sits on is what makes the + symbol mean *first* or *third* angle, and it is a convention that has to be + read off the standard's figure: the projection rule alone cannot pick it, + because the same picture is a legal drawing of the mirrored object seen + from the other side. + + ISO 5456-2's own figure is behind ISO's paywall and was not read. Two + independent reproductions of it were downloaded and measured instead, and + they agree on every landmark used here: + + * FreeCAD's ISO 5457 sheet template, first-angle symbol + (`src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg`, + ids `first_angle_trapezoid` / `first_angle_*_circle`): the trapezoid is + `m 389,222 -10,2.5 v 5 l 10,2.5 z` -- a 5-unit side at x = 379 and a + 10-unit side at x = 389 -- and the circles (r 2.5 and r 5) are at + cx = 396. Short side LEFT; end view to the RIGHT of the cone. + * Wikimedia Commons `Convention placement vues dessin technique.svg`, which + draws both symbols side by side, labelled FR and US. FR (first angle): + trapezoid 35.856 units tall at x = 13.343 and 66.135 at x = 81.869, + circles at x = 148.005. US (third angle): trapezoid 35.856 at + x = 380.275 and 66.135 at x = 448.801, circles at x = 314.140. + + So, in both symbols alike, the trapezoid's SHORT side is on the left; only + the end view changes side: + + first angle cone, then the circles to its RIGHT + third angle the circles, then the cone to their RIGHT + + which is the same thing as the rule the standard is usually quoted by -- + the short side points AWAY from the circles in first angle and TOWARDS + them in third. `tests/test_sheet_titleblock.py` pins the ordering against + the measured figures above, so this cannot be "fixed" back by eye. The proportions (large circle diameter 8, small 4, cone 8 mm long) are this - module's; ISO 5456-2's proportion table is not transcribed. The arrangement - is the part that can be *wrong* on a drawing, and it is derived above. + module's; ISO 5456-2's proportion table is not transcribed. They keep the + 1:2 small-to-large ratio both reference figures' circles have. """ if angle not in PROJECTION_ANGLES: raise ValueError(f"projection angle must be one of {PROJECTION_ANGLES}, got {angle!r}") cx, cy = float(at[0]), float(at[1]) half = (PROJECTION_CONE_LENGTH + PROJECTION_GAP + 2 * PROJECTION_R) / 2.0 + # The end view sits right of the cone in first angle, left of it in third. sign = 1.0 if angle == "first" else -1.0 - cone_far = -half * sign - cone_near = cone_far + sign * PROJECTION_CONE_LENGTH circles_x = (half - PROJECTION_R) * sign + # The trapezoid itself is the same picture in both symbols: short side left. + cone_left = -half if angle == "first" else half - PROJECTION_CONE_LENGTH + cone_right = cone_left + PROJECTION_CONE_LENGTH cone = Poly( points=( - (cx + cone_far, cy - PROJECTION_SMALL_R), - (cx + cone_near, cy - PROJECTION_R), - (cx + cone_near, cy + PROJECTION_R), - (cx + cone_far, cy + PROJECTION_SMALL_R), + (cx + cone_left, cy - PROJECTION_SMALL_R), + (cx + cone_right, cy - PROJECTION_R), + (cx + cone_right, cy + PROJECTION_R), + (cx + cone_left, cy + PROJECTION_SMALL_R), ), closed=True, ) From 499b642fbde5a91800526f4d8a8090d34478b71b Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 20:48:50 +0300 Subject: [PATCH 4/7] feat(sheet): revisions, the ISO 7573 parts list and ISO 6433 balloons SECTION 24 grows to six tools: revision_add, bom_extract, bom_table, balloon_add. bom_extract reads. It walks the INSERTs through the generic members already in the contract -- entity_list, block_get_attributes, entity_get_xdata -- and returns parts-list rows from the ACADMCP_MECH std_part payload, falling back to the block's own ATTRIBs and reporting which of the two each row came from. A block with neither is skipped rather than guessed at, and the tool never writes to the drawing. bom_table draws the rows as a real TABLE, 180 mm wide (ISO 7573: the item list is as wide as the title block), growing upwards from directly above it with the heading row against the block and item 1 the row above -- which is what ISO 7573 asks for on a drawing. A TABLE is drawn downwards from its insertion point, so the upward list is inserted a table height above its anchor with the heading as the last row; one table_layout feeds both the real TABLE and the pure table_prims, so the two renderings cannot disagree. balloon_add draws the ISO 6433 reference -- numeral, leader, dot on the item -- and writes the balloon payload naming its targets. A rerun over the same targets renumbers that balloon instead of drawing a second one, and an item number already used for different targets is refused by name with the other balloon's handle. revision_add appends a row to the revision block, is idempotent per revision code (a repeat draws nothing at all), and optionally clouds the changed regions and tags them with the triangle. Clouds are refused up front, carrying capability "revcloud", on an engine that has no revcloud member -- the COM backend -- rather than leaving half a revision behind. Declared: ISO 7200 does not standardise the revision block's geometry and ISO 7573 does not fix the item list's column widths or row height, so both reuse this repository's 180 mm / 7 mm ruling and say so in their SOURCE docstrings. ISO 6433's "numeral at least twice the dimension character height" is met by the numeral being 1.4x the balloon radius: the 4 mm default clears 2.5 mm dimension text, and balloon_add's docstring tells a sheet dimensioned at 3.5 mm to pass radius=5.0. Three corrections to what the plan wrote, each measured rather than assumed: * extract_records read `block_get_attributes(...)["attributes"]`. Both engines return the flat {tag: value} mapping itself (ezdxf_backend.py:6687 builds it from `ent.attribs`, com_backend.py:5703 from `ref.GetAttributes()` as {TagString: TextString}), so every attribute fallback would have been silently empty -- measured headlessly, where a PLATE insert carrying only a DESIGNATION ATTRIB extracted to nothing. * draw_bom_table now reports `entity_type` / `representation` / `child_handles`. `entity_create_table` returns EntityInfo(type="TABLE") whose handle is the first CHILD of a composite on the headless engine, so reading that handle back gives a rule LINE, not an ACAD_TABLE -- the contract tests/test_table_leader.py already pins. A caller that means to edit the table afterwards has to be told which of the two it got. * add_revision reports one `tags` entry per tag asked for ({at, handles}), not one per entity: a tag is two primitives, so a flat handle list claimed twice as many tags as the caller requested. Discovery: `bom_extract` / `bom_table` / `balloon_add` each entered the index heavy with "parts"/"list" tokens and took top-1 for the bare query "parts list" away from `entity_create_table` (a BM25 tie broken alphabetically). Narrowed on my side only -- the reader speaks of the bill of materials, the drawer names a table, the balloon names an item -- never by touching the golden case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 8 +- discovery/aliases.py | 63 ++++ docs/tool-inventory.json | 61 +++- engineering/sheet/__init__.py | 42 +++ engineering/sheet/bom.py | 521 ++++++++++++++++++++++++++++++++++ engineering/sheet/revision.py | 383 +++++++++++++++++++++++++ server.py | 256 ++++++++++++++++- tests/test_sheet_bom.py | 296 +++++++++++++++++++ tests/test_sheet_revision.py | 148 ++++++++++ tests/test_tool_registry.py | 9 +- 10 files changed, 1777 insertions(+), 10 deletions(-) create mode 100644 engineering/sheet/bom.py create mode 100644 engineering/sheet/revision.py create mode 100644 tests/test_sheet_bom.py create mode 100644 tests/test_sheet_revision.py diff --git a/README.md b/README.md index 9e50eda..2e3fd90 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,15 @@ 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. -> 206 is the **registered** count; a default install advertises 201 over `tools/list`, +> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 210 is the **registered** count; a default install advertises 205 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists **A big MCP server is expensive to be connected to.** The full catalog costs a client **59,315 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**. -**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **187 AutoCAD command names and 958 synonym phrases** covering all 206 tools. A test refuses to let a tool exist without one. +**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **187 AutoCAD command names and 990 synonym phrases** covering all 210 tools. A test refuses to let a tool exist without one. | Advertised surface | Tools seen | Idle cost | |---|---:|---:| @@ -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. +210 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 1b2dd45..f3c6d72 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -91,6 +91,11 @@ 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. +# REVCLOUD -- one command draws a bare cloud (entity_create_revcloud) or a +# revision cloud that also files a revision row (revision_add); a +# drafter typing it could mean either. +# TABLE -- one command makes an empty table (entity_create_table) or the +# ISO 7573 parts list (bom_table); both are correct destinations. # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( @@ -112,9 +117,11 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( "PLINE", "PLOT", "QSELECT", + "REVCLOUD", "SAVEAS", "SETVAR", "STYLE", + "TABLE", "UCS", "VIEW", "ZOOM", @@ -964,6 +971,62 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "baslik blogu", ), ), + "revision_add": ToolAliases( + acad=("REVCLOUD",), + synonyms=( + "revision", + "revision block", + "revision cloud", + "revision table", + "change note", + "engineering change", + "as built change", + "revizyon", + "degisiklik", + ), + ), + "bom_extract": ToolAliases( + synonyms=( + "bill of materials", + # Deliberately NOT "parts list" or "count the parts": the bare + # "parts list" query belongs to the tool that DRAWS one + # (entity_create_table / bom_table), and every extra "parts"/"list" + # token here moved this reader above it on plain BM25 mass. + "read the bom", + "bom", + "item list", + "what is on this drawing", + "iso 7573", + "parca listesi", + "malzeme listesi", + ), + ), + "bom_table": ToolAliases( + acad=("TABLE",), + synonyms=( + # Every phrase here names a TABLE. The bare "parts list" query is + # `entity_create_table`'s (the generic table that a parts list is + # one use of); this tool answers when the drafter asks for the + # ISO 7573 one by name. + "bom table", + "item list table", + "materials table", + "iso 7573 table", + "parca listesi tablosu", + ), + ), + "balloon_add": ToolAliases( + synonyms=( + "balloon", + "item reference", + "item number", + "position number", + "iso 6433", + "number the items", + "balon", + "pozisyon numarasi", + ), + ), # ── Layers & linetypes ────────────────────────────────────────────────── "layer_create": ToolAliases( acad=("LAYER", "LA"), diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 7615cd9..e25b609 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": 210, "resources": 8, "prompts": 5, "groups": 23 @@ -15,7 +15,7 @@ "corner_ops": 4, "dimensions": 5, "drawing": 11, - "engineering": 12, + "engineering": 16, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, @@ -178,6 +178,20 @@ "readOnlyHint": true } }, + { + "name": "balloon_add", + "group": "engineering", + "cost": "mutate", + "summary": "Add an ISO 6433 balloon linked to its item row.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: Add Balloon", + "destructiveHint": false + } + }, { "name": "batch_plot", "group": "page_setup", @@ -303,6 +317,35 @@ "readOnlyHint": false } }, + { + "name": "bom_extract", + "group": "engineering", + "cost": "read", + "summary": "Read the bill of materials out of the drawing's block references.", + "tags": [ + "engineering", + "query", + "sheet" + ], + "annotations": { + "title": "Sheet: Extract Bill of Materials", + "readOnlyHint": true + } + }, + { + "name": "bom_table", + "group": "engineering", + "cost": "mutate", + "summary": "Draw the parts list as a real TABLE above the title block.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: Draw Parts List", + "destructiveHint": false + } + }, { "name": "boundary_from_entities", "group": "analysis", @@ -2212,6 +2255,20 @@ "readOnlyHint": true } }, + { + "name": "revision_add", + "group": "engineering", + "cost": "mutate", + "summary": "Append a revision row, with optional revision clouds and triangular tags.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: Add Revision", + "destructiveHint": false + } + }, { "name": "selection_filter", "group": "entity_query", diff --git a/engineering/sheet/__init__.py b/engineering/sheet/__init__.py index bd227a3..76f1438 100644 --- a/engineering/sheet/__init__.py +++ b/engineering/sheet/__init__.py @@ -1,6 +1,20 @@ """The sheet standard: ISO 5457 frames, ISO 7200 title blocks, ISO 7573 parts lists, ISO 6433 balloons and the delivery formats.""" +from engineering.sheet.bom import ( + BOM_COLUMNS, + COLUMN_LABELS, + DEFAULT_COLUMNS, + LIST_WIDTH, + ROW_HEIGHT, + add_balloon, + balloon_prims, + draw_bom_table, + extract_records, + read_balloons, + rows_from_records, + table_prims, +) from engineering.sheet.frames import ( CENTRING_OVERSHOOT, EDGE_MARGIN, @@ -20,11 +34,26 @@ from engineering.sheet.frames import ( sheet_size, zone_divisions, ) +from engineering.sheet.revision import ( + REVISION_COLUMNS, + add_revision, + read_revisions, + revision_block_prims, + revision_heading_prims, + revision_row_prims, + revision_tag_prims, +) __all__ = [ + "BOM_COLUMNS", "CENTRING_OVERSHOOT", + "COLUMN_LABELS", + "DEFAULT_COLUMNS", "EDGE_MARGIN", "FILING_MARGIN", + "LIST_WIDTH", + "REVISION_COLUMNS", + "ROW_HEIGHT", "SHEETS", "SHEET_LAYER", "SHEET_TEXT_LAYER", @@ -32,11 +61,24 @@ __all__ = [ "TRIM_MARK_SHORT", "ZONE_DIVISIONS", "ZONE_MODULE", + "add_balloon", + "add_revision", + "balloon_prims", + "draw_bom_table", "draw_sheet_frame", "draw_sheet_prims", "enter_layout", + "extract_records", "frame_metrics", "frame_prims", + "read_balloons", + "read_revisions", + "revision_block_prims", + "revision_heading_prims", + "revision_row_prims", + "revision_tag_prims", + "rows_from_records", "sheet_size", + "table_prims", "zone_divisions", ] diff --git a/engineering/sheet/bom.py b/engineering/sheet/bom.py new file mode 100644 index 0000000..073bdfd --- /dev/null +++ b/engineering/sheet/bom.py @@ -0,0 +1,521 @@ +"""ISO 7573 parts lists and ISO 6433 item references. + +SOURCE +------ +Parts list ISO 7573:2008 -- an item list carries at least an item reference + (the position number), a quantity, a designation and a reference + to the document or standard that defines the item. When the list + is drawn on the drawing it is placed directly above the title + block, has the same width as it, and the item numbers ascend + upwards so the list can be extended -- which puts the heading row + at the bottom, against the title block. `direction="down"` draws + the same list the other way up, for a list on its own sheet. +Balloons ISO 6433:2012 -- an item reference is a numeral placed outside the + outline of the item and connected to it by a leader line that + terminates in a dot on the item. + +NOT TRANSCRIBED, and therefore not asserted anywhere as if it were the +standard's: + +* The column widths. ISO 7573 fixes neither the widths nor the order; the + shares below total the 180 mm title-block width, which *is* the standard's + rule, and any column set is rescaled to that total. +* The row height. 7 mm is this repository's `entity_create_table` default. +* ISO 6433's rule that the numeral is at least twice the height of the + dimension characters. The numeral here is 1.4 * the balloon radius, so the + default radius of 4 mm gives a 5.6 mm numeral -- which clears 2 x 2.5 mm + dimension text but not 2 x 3.5 mm. A drawing dimensioned at 3.5 mm should + pass `radius=5.0`, and the docstring of `balloon_prims` says so. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Circle, Line, Prim, Pt, Text +from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values +from engineering.sheet.frames import SHEET_TEXT_LAYER, draw_sheet_prims, enter_layout + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "BALLOON_DOT_RADIUS", + "BOM_COLUMNS", + "COLUMN_LABELS", + "COLUMN_SHARES", + "DEFAULT_COLUMNS", + "DIRECTIONS", + "LIST_STANDARDS", + "LIST_WIDTH", + "ROW_HEIGHT", + "add_balloon", + "balloon_prims", + "draw_bom_table", + "extract_records", + "read_balloons", + "rows_from_records", + "table_layout", + "table_prims", +] + +LIST_WIDTH = 180.0 # ISO 7573: the item list is as wide as the title block. +ROW_HEIGHT = 7.0 +TEXT_HEIGHT = 2.5 +BALLOON_DOT_RADIUS = 0.5 +DIRECTIONS = ("up", "down") +LIST_STANDARDS = ("ISO 7573",) + +#: Every column this module knows how to print, in the order it prints them. +BOM_COLUMNS: tuple[str, ...] = ( + "item", + "qty", + "designation", + "standard", + "material", + "mass", + "remarks", +) + +#: The four ISO 7573 minimum columns plus the material this shop always wants. +DEFAULT_COLUMNS: tuple[str, ...] = ("item", "qty", "designation", "standard", "material") + +COLUMN_LABELS: dict[str, str] = { + "item": "POS", + "qty": "QTY", + "designation": "DESIGNATION", + "standard": "STANDARD", + "material": "MATERIAL", + "mass": "MASS", + "remarks": "REMARKS", +} + +#: Relative widths; `table_layout` rescales the chosen set to LIST_WIDTH. The +#: default five happen to total 180.0, so their scale factor is exactly 1. +COLUMN_SHARES: dict[str, float] = { + "item": 12.0, + "qty": 14.0, + "designation": 74.0, + "standard": 46.0, + "material": 34.0, + "mass": 22.0, + "remarks": 40.0, +} + + +def _check_columns(columns) -> tuple[str, ...]: + chosen = tuple(DEFAULT_COLUMNS if columns is None else columns) + unknown = [c for c in chosen if c not in BOM_COLUMNS] + if unknown: + raise ValueError( + f"unknown parts-list column(s) {', '.join(unknown)}; " + f"available: {', '.join(BOM_COLUMNS)}" + ) + if "item" not in chosen: + raise ValueError("ISO 7573: the item reference column 'item' cannot be dropped") + return chosen + + +def rows_from_records(records, *, columns=None, group_by: str | None = "designation"): + """Collapse extracted INSERT records into numbered parts-list rows. + + `group_by` is a record key (`"designation"`, `"standard"`, ...) or None for + one row per insert. Rows are sorted by the grouping value so a rerun on the + same drawing numbers the same parts the same way; `handles` carries every + INSERT the row stands for, which is what a balloon links to. + """ + chosen = _check_columns(columns) + if group_by is not None and group_by not in BOM_COLUMNS: + raise ValueError( + f"group_by must be None or one of {', '.join(BOM_COLUMNS)}; got {group_by!r}" + ) + buckets: dict[str, dict] = {} + order: list[str] = [] + for index, record in enumerate(records): + key = str(record.get(group_by, "")) if group_by is not None else f"#{index:06d}" + bucket = buckets.get(key) + if bucket is None: + bucket = {c: record.get(c, "") for c in chosen if c != "item"} + bucket["qty"] = 0 + bucket["handles"] = [] + bucket["_key"] = key + buckets[key] = bucket + order.append(key) + bucket["qty"] += int(record.get("qty", 1) or 1) + handle = record.get("handle") + if handle: + bucket["handles"].append(str(handle)) + if group_by is not None: + order.sort() + rows: list[dict] = [] + for item, key in enumerate(order, start=1): + bucket = buckets[key] + bucket.pop("_key", None) + bucket["handles"] = tuple(bucket["handles"]) + bucket["item"] = item + rows.append({c: bucket.get(c, "") for c in chosen} | {"handles": bucket["handles"]}) + return tuple(rows) + + +def table_layout( + rows, + *, + at: Pt, + direction: str = "up", + columns=None, + row_height: float = ROW_HEIGHT, +): + """Everything both renderers need, so the prims and the real TABLE agree. + + `at` is the anchor: the list's bottom-left corner for `direction="up"` (it + sits on the title block) and its top-left corner for `direction="down"`. + `insert` is where `entity_create_table` must start, since a TABLE is drawn + downwards from its insertion point. + """ + if direction not in DIRECTIONS: + raise ValueError(f"direction must be one of {DIRECTIONS}, got {direction!r}") + chosen = _check_columns(columns) + if row_height <= 0: + raise ValueError(f"row_height must be positive, got {row_height!r}") + raw = [COLUMN_SHARES[c] for c in chosen] + scale = LIST_WIDTH / sum(raw) + widths = [w * scale for w in raw] + heading = [COLUMN_LABELS[c] for c in chosen] + body = [[str(row.get(c, "")) for c in chosen] for row in rows] + cells = list(reversed(body)) + [heading] if direction == "up" else [heading] + body + height = row_height * len(cells) + ax, ay = float(at[0]), float(at[1]) + insert = (ax, ay + height) if direction == "up" else (ax, ay) + return { + "columns": chosen, + "labels": heading, + "widths": widths, + "width": sum(widths), + "height": height, + "row_height": row_height, + "direction": direction, + "anchor": (ax, ay), + "insert": insert, + "cells": cells, + } + + +def table_prims( + rows, + *, + at: Pt, + direction: str = "up", + standard: str = "ISO 7573", + height: float = ROW_HEIGHT, +) -> tuple[Prim, ...]: + """The parts list as primitive descriptions, for a caller that does not + want a real TABLE (and for the tests that pin the layout).""" + if standard not in LIST_STANDARDS: + raise ValueError( + f"parts-list standard {standard!r} is not transcribed; " + f"available: {', '.join(LIST_STANDARDS)}" + ) + layout = table_layout(rows, at=at, direction=direction, row_height=height) + ax, ay = layout["anchor"] + total_w, total_h = layout["width"], layout["height"] + prims: list[Prim] = [] + for index in range(len(layout["cells"]) + 1): + y = ay + index * layout["row_height"] + prims.append(Line(p1=(ax, y), p2=(ax + total_w, y))) + x = ax + prims.append(Line(p1=(x, ay), p2=(x, ay + total_h))) + for width in layout["widths"]: + x += width + prims.append(Line(p1=(x, ay), p2=(x, ay + total_h))) + pad = min(1.0, layout["row_height"] * 0.15) + for row_index, cells in enumerate(layout["cells"]): + # cells are top-to-bottom; the top row is the last one up the sheet + baseline = ay + (len(layout["cells"]) - row_index - 1) * layout["row_height"] + pad + x = ax + for column_index, value in enumerate(cells): + if value: + prims.append(Text(at=(x + pad, baseline), text=value, height=TEXT_HEIGHT)) + x += layout["widths"][column_index] + return tuple(prims) + + +def balloon_prims( + item: int, + at: Pt, + leader_to: Pt, + *, + radius: float = 4.0, + height: float | None = None, +) -> tuple[Prim, ...]: + """One ISO 6433 item reference: the balloon, the leader, the dot, the numeral. + + The numeral is 1.4 * `radius` high. ISO 6433 asks for at least twice the + dimension character height, so a sheet dimensioned at 3.5 mm wants + `radius=5.0` (a 7 mm numeral); the 4 mm default clears 2.5 mm dimensions. + """ + number = int(item) + if number <= 0: + raise ValueError(f"an ISO 6433 item reference is a positive integer; got {item!r}") + if radius <= 0: + raise ValueError(f"balloon radius must be positive; got {radius!r}") + text_height = float(radius) * 1.4 if height is None else float(height) + cx, cy = float(at[0]), float(at[1]) + tx, ty = float(leader_to[0]), float(leader_to[1]) + distance = math.hypot(tx - cx, ty - cy) + if distance <= radius: + raise ValueError( + f"balloon {number}: the leader target ({tx}, {ty}) is inside the balloon at " + f"({cx}, {cy}) r={radius}; ISO 6433 puts the reference outside the item" + ) + ux, uy = (tx - cx) / distance, (ty - cy) / distance + label = str(number) + return ( + Circle(center=(cx, cy), radius=float(radius)), + Line(p1=(cx + ux * radius, cy + uy * radius), p2=(tx, ty)), + Circle(center=(tx, ty), radius=BALLOON_DOT_RADIUS), + Text( + at=(cx - 0.3 * text_height * len(label), cy - 0.5 * text_height), + text=label, + height=text_height, + ), + ) + + +#: Block attributes read when an INSERT carries no ACADMCP_MECH payload. +_ATTRIBUTE_KEYS = { + "designation": ("DESIGNATION", "DESIG", "NAME", "TAG"), + "standard": ("STANDARD", "STD", "NORM"), + "material": ("MATERIAL", "MAT"), + "size": ("SIZE",), + "qty": ("QTY", "QUANTITY"), + "remarks": ("REMARKS", "NOTE"), +} + + +def _from_attributes(attributes: dict) -> dict: + upper = {str(k).upper(): v for k, v in (attributes or {}).items()} + record: dict = {} + for field, keys in _ATTRIBUTE_KEYS.items(): + for key in keys: + if key in upper and str(upper[key]).strip(): + record[field] = str(upper[key]).strip() + break + return record + + +async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int = 200): + """Walk the INSERTs and return one parts-list record per block reference. + + Reads only: the entity listing, each INSERT's attributes and its + ACADMCP_MECH payload. Nothing is written, so this is safe on a drawing that + is not ours. `source` is "xdata" when the payload supplied the row and + "attributes" when the block's ATTRIBs did; a block with neither is skipped + rather than guessed at. + """ + inserts = await backend.entity_list(type_filter="INSERT", layer_filter=layer, limit=limit) + records: list[dict] = [] + for entity in inserts: + payload: dict = {} + try: + raw = await backend.entity_get_xdata(entity.handle, APP_ID) + payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {} + except (ValueError, KeyError, TypeError): + payload = {} + if payload.get("kind") != "std_part": + payload = {} + # Measured on both engines: `block_get_attributes` returns the flat + # ``{tag: value}`` mapping itself, not ``{"attributes": {...}}`` + # (ezdxf_backend.py:6687 builds it from ``ent.attribs``, com_backend.py:5703 + # from ``ref.GetAttributes()``). Reading a wrapper key here would have + # made every attribute fallback silently empty. + attributes = {} + try: + attributes = await backend.block_get_attributes(entity.handle) + except Exception: # noqa: BLE001 - a block with no ATTRIBs is not an error here + attributes = {} + fallback = _from_attributes(attributes) + designation = str(payload.get("designation") or fallback.get("designation") or "").strip() + if not designation: + continue + records.append( + { + "handle": entity.handle, + "block": entity.properties.get("block_name", ""), + "layer": entity.layer, + "designation": designation, + "standard": str(payload.get("standard") or fallback.get("standard") or ""), + "size": str(payload.get("size") or fallback.get("size") or ""), + "material": str(payload.get("material") or fallback.get("material") or ""), + "mass": str(payload.get("mass") or ""), + "remarks": str(payload.get("remarks") or fallback.get("remarks") or ""), + "qty": int(payload.get("qty") or fallback.get("qty") or 1), + "source": "xdata" if payload else "attributes", + } + ) + return tuple(records) + + +async def draw_bom_table( + backend: AutoCADBackend, + rows, + *, + at: Pt, + direction: str = "up", + columns=None, + row_height: float = ROW_HEIGHT, + layer: str = SHEET_TEXT_LAYER, + layout: str | None = None, +) -> dict: + """Draw the parts list as a real TABLE entity. + + `entity_create_table` grows downwards from its insertion point, so an + upward-growing ISO 7573 list is drawn from an insertion one table height + above the anchor, with the heading as the last (bottom) row. + + `representation` is reported rather than assumed: the live engine draws a + real ACAD_TABLE, while the headless one returns a *composite* whose + ``handle`` is its first child rule (``tests/test_table_leader.py`` pins + that contract). A caller that means to edit the table afterwards has to + know which of the two it got. + """ + if not rows: + raise ValueError("bom_table: there are no parts-list rows to draw") + spec = table_layout(rows, at=at, direction=direction, columns=columns, row_height=row_height) + previous = await enter_layout(backend, layout, caller="bom_table") + try: + entity = await backend.entity_create_table( + spec["insert"][0], + spec["insert"][1], + spec["cells"], + headers=None, + column_widths=spec["widths"], + row_height=spec["row_height"], + text_height=TEXT_HEIGHT, + layer=layer, + ) + finally: + if layout is not None: + await backend.layout_set_current(previous) + ax, ay = spec["anchor"] + return { + "ok": True, + "handle": entity.handle, + "entity_type": entity.type, + "representation": entity.properties.get("representation", "native"), + "child_handles": list(entity.properties.get("child_handles") or []), + "standard": "ISO 7573", + "direction": direction, + "columns": list(spec["columns"]), + "rows": len(rows), + "insert": spec["insert"], + "layout": layout or "Model", + "bbox": {"min": [ax, ay], "max": [ax + spec["width"], ay + spec["height"]]}, + } + + +async def read_balloons(backend: AutoCADBackend): + """Every ISO 6433 balloon this server drew, from its ACADMCP_MECH payload.""" + found: list[dict] = [] + for entity in await backend.entity_list(type_filter="CIRCLE"): + try: + raw = await backend.entity_get_xdata(entity.handle, APP_ID) + payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {} + except (ValueError, KeyError, TypeError): + continue + if payload.get("kind") != "balloon": + continue + found.append( + { + "handle": entity.handle, + "item": int(payload.get("item", 0)), + "targets": list(payload.get("targets") or []), + "text": payload.get("text", ""), + } + ) + found.sort(key=lambda row: row["item"]) + return tuple(found) + + +async def add_balloon( + backend: AutoCADBackend, + *, + item: int, + at: Pt, + leader_to: Pt, + targets=(), + radius: float = 4.0, + layer: str = "DIM", + layout: str | None = None, +) -> dict: + """Draw (or renumber) one ISO 6433 item reference. + + A balloon already pointing at the same targets is *renumbered* -- its + numeral is edited and its payload rewritten -- so running the balloon pass + again after the parts list is regrouped does not litter the sheet with + duplicates. An item number already used for a different set of targets is + refused before anything is drawn: ISO 129-1 and ISO 6433 both want one + reference per item. + """ + number = int(item) + wanted = tuple(sorted(str(t) for t in targets)) + prims = balloon_prims(number, at, leader_to, radius=radius) # validates first + existing = await read_balloons(backend) + for balloon in existing: + if tuple(sorted(balloon["targets"])) == wanted and wanted: + if balloon["text"]: + await backend.entity_edit_text(balloon["text"], text=str(number)) + payload = { + "v": PAYLOAD_VERSION, + "kind": "balloon", + "item": number, + "targets": list(wanted), + "text": balloon["text"], + } + await backend.entity_set_xdata(balloon["handle"], APP_ID, to_values(payload)) + return { + "ok": True, + "renumbered": True, + "item": number, + "handle": balloon["handle"], + "text": balloon["text"], + "targets": list(wanted), + "layout": layout or "Model", + } + clash = next( + (b for b in existing if b["item"] == number and tuple(sorted(b["targets"])) != wanted), + None, + ) + if clash is not None: + raise ValueError( + f"item reference {number} is already used by balloon {clash['handle']} for " + f"targets {clash['targets']}; ISO 6433 gives each item one reference" + ) + + previous = await enter_layout(backend, layout, caller="balloon_add") + try: + handles = await draw_sheet_prims( + backend, prims, layer_map={"visible": layer, "text": layer, "center": layer} + ) + finally: + if layout is not None: + await backend.layout_set_current(previous) + payload = { + "v": PAYLOAD_VERSION, + "kind": "balloon", + "item": number, + "targets": list(wanted), + "text": handles[3], + } + await backend.entity_set_xdata(handles[0], APP_ID, to_values(payload)) + return { + "ok": True, + "renumbered": False, + "item": number, + "handle": handles[0], + "leader": handles[1], + "dot": handles[2], + "text": handles[3], + "targets": list(wanted), + "layout": layout or "Model", + } diff --git a/engineering/sheet/revision.py b/engineering/sheet/revision.py new file mode 100644 index 0000000..cd2a748 --- /dev/null +++ b/engineering/sheet/revision.py @@ -0,0 +1,383 @@ +"""The revision block, revision clouds and the triangular revision tags. + +SOURCE +------ +ISO 7200:2004 lists `version` (the revision code) among the title block's data +fields, and a drawing that has been revised records each version with its +description, date and issuer. ISO 7200 does NOT standardise the revision +block's geometry, so the block here reuses the ISO 7573 item-list ruling: the +same 180 mm width as the title block and the same 7 mm rows. That is a layout +decision, and it is written down here rather than asserted as a standard. + +The triangular revision tag and the revision cloud are drawing-office practice, +not ISO geometry: the cloud marks the changed region and the triangle carries +the revision code that the block's row explains. `entity_create_revcloud` draws +the cloud, so a revision *with* clouds is refused up front on an engine that +has no revcloud (the COM backend: `revcloud` is false there, no ActiveX member, +command-line only) rather than half-written. + +Placement: the block defaults to the upper-right corner of the drawing frame +and grows downwards, so it does not fight the ISO 7573 item list for the space +directly above the title block. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING + +from backends.xdata_specs import MAX_BYTES, app_size +from engineering.mech.primitives import Line, Poly, Prim, Pt, Text +from engineering.sheet.bom import LIST_WIDTH, ROW_HEIGHT, TEXT_HEIGHT +from engineering.sheet.frames import ( + SHEET_LAYER, + draw_sheet_prims, + enter_layout, + frame_metrics, +) + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "REVISION_COLUMNS", + "REVISION_LABELS", + "REVISION_SHARES", + "REV_APP_ID", + "REV_PAYLOAD_VERSION", + "REV_RE", + "TAG_SIDE", + "add_revision", + "read_revisions", + "rev_payload", + "rev_values", + "revision_block_prims", + "revision_heading_prims", + "revision_row_prims", + "revision_tag_prims", +] + +REVISION_COLUMNS: tuple[str, ...] = ("rev", "description", "date", "by") +REVISION_LABELS: dict[str, str] = { + "rev": "REV", + "description": "DESCRIPTION", + "date": "DATE", + "by": "BY", +} +#: Widths in millimetres; they total the 180 mm the title block is wide. +REVISION_SHARES: dict[str, float] = { + "rev": 16.0, + "description": 96.0, + "date": 40.0, + "by": 28.0, +} +TAG_SIDE = 7.0 +TAG_TEXT_HEIGHT = 3.5 + +#: A revision code: one to three capitals or digits (ISO 7200's `version` +#: field is free text; this is the shop's form and keeps the tag legible). +REV_RE = re.compile(r"^[A-Z0-9]{1,3}$") + + +def _check_rev(rev: str) -> str: + code = str(rev).strip().upper() + if not REV_RE.fullmatch(code): + raise ValueError( + f"revision code {rev!r} must be one to three capitals or digits (A, B, 01, ...)" + ) + return code + + +def revision_tag_prims(rev: str, at: Pt, *, side: float = TAG_SIDE) -> tuple[Prim, ...]: + """An equilateral triangle carrying the revision code, apex up, `at` its + lower-left corner.""" + code = _check_rev(rev) + if side <= 0: + raise ValueError(f"revision tag side must be positive; got {side!r}") + x, y = float(at[0]), float(at[1]) + height = side * (3.0**0.5) / 2.0 + triangle = Poly( + points=((x, y), (x + side, y), (x + side / 2.0, y + height)), + closed=True, + ) + label = Text( + at=(x + side / 2.0 - 0.3 * TAG_TEXT_HEIGHT * len(code), y + height * 0.25), + text=code, + height=TAG_TEXT_HEIGHT, + ) + return (triangle, label) + + +def _band(cells: dict, *, ax: float, top: float, height: float) -> list[Prim]: + """One horizontal band: its bottom rule, its five verticals and its text. + + The band's top rule belongs to the band above it (or to + `revision_heading_prims`), so bands stack without drawing a rule twice -- + which is what lets `add_revision` append one band at a time and still + produce the block `revision_block_prims` describes. + """ + bottom = top - height + widths = [REVISION_SHARES[c] for c in REVISION_COLUMNS] + prims: list[Prim] = [Line(p1=(ax, bottom), p2=(ax + LIST_WIDTH, bottom))] + x = ax + prims.append(Line(p1=(x, top), p2=(x, bottom))) + for width in widths: + x += width + prims.append(Line(p1=(x, top), p2=(x, bottom))) + pad = min(1.0, height * 0.15) + x = ax + for column_index, column in enumerate(REVISION_COLUMNS): + value = str(cells.get(column, "")) + if value: + prims.append(Text(at=(x + pad, bottom + pad), text=value, height=TEXT_HEIGHT)) + x += widths[column_index] + return prims + + +def revision_heading_prims(*, at: Pt, height: float = ROW_HEIGHT) -> tuple[Prim, ...]: + """The block's top rule and its heading band. `at` is the TOP-left corner.""" + if height <= 0: + raise ValueError(f"row height must be positive; got {height!r}") + ax, ay = float(at[0]), float(at[1]) + heading = {c: REVISION_LABELS[c] for c in REVISION_COLUMNS} + return ( + Line(p1=(ax, ay), p2=(ax + LIST_WIDTH, ay)), + *_band(heading, ax=ax, top=ay, height=height), + ) + + +def revision_row_prims( + row: dict, *, at: Pt, index: int, height: float = ROW_HEIGHT +) -> tuple[Prim, ...]: + """One revision band, `index` rows below the heading. Its first Text is the + REV cell -- the primitive `add_revision` hangs the payload on.""" + if height <= 0: + raise ValueError(f"row height must be positive; got {height!r}") + if int(index) < 0: + raise ValueError(f"row index must be zero or more; got {index!r}") + ax, ay = float(at[0]), float(at[1]) + return tuple(_band(row, ax=ax, top=ay - (int(index) + 1) * height, height=height)) + + +#: The revision row's own XDATA application. +#: +#: `engineering/mech/xdata.py::KINDS` is pinned to ("part", "std_part", +#: "balloon") and `engineering/mech/critique.py` scans ACADMCP_MECH for exactly +#: those three on INSERTs and CIRCLEs. A revision row is neither, so it carries +#: its own application rather than a fourth kind that `encode` would refuse. +#: The chunking is the same rule the mech codec follows -- ASCII JSON split at +#: 255 characters -- and `entity_set_xdata(handle, app_name, values)` supplies +#: the 1001 application tag itself, so only the chunks travel. +REV_APP_ID = "ACADMCP_SHEET_REV" +REV_PAYLOAD_VERSION = 1 +_REV_CHUNK = 255 + + +def rev_values(payload: dict) -> list[str]: + """The chunk strings for one revision record. Refused before anything is written.""" + if not isinstance(payload, dict): + raise TypeError(f"{REV_APP_ID}: a payload must be a dict, got {type(payload).__name__}") + version = payload.get("v", REV_PAYLOAD_VERSION) + if version != REV_PAYLOAD_VERSION: + raise ValueError( + f"{REV_APP_ID}: payload version {version!r}; this build writes and reads " + f"version {REV_PAYLOAD_VERSION}" + ) + text = json.dumps( + {**payload, "v": REV_PAYLOAD_VERSION}, + separators=(",", ":"), + sort_keys=True, + ensure_ascii=True, + ) + values = [text[i : i + _REV_CHUNK] for i in range(0, len(text), _REV_CHUNK)] + size = app_size(REV_APP_ID, [(1000, value) for value in values]) + if size > MAX_BYTES: + raise ValueError( + f"{REV_APP_ID}: the record encodes to {size} bytes; the limit is 16 KB per " + "entity across every application -- shorten the description" + ) + return values + + +def rev_payload(values) -> dict: + """The record back, or a ValueError. Never a plausible-looking guess.""" + parts = [value if isinstance(value, str) else str(value[1]) for value in values] + try: + payload = json.loads("".join(parts)) + except json.JSONDecodeError as exc: + raise ValueError(f"{REV_APP_ID}: the chunk stream is not JSON ({exc.msg})") from exc + if not isinstance(payload, dict): + raise ValueError( + f"{REV_APP_ID}: the payload decoded to {type(payload).__name__}, not a dict" + ) + if payload.get("v") != REV_PAYLOAD_VERSION: + raise ValueError(f"{REV_APP_ID}: payload version {payload.get('v')!r} is not readable here") + return payload + + +def revision_block_prims(rows, *, at: Pt, height: float = ROW_HEIGHT) -> tuple[Prim, ...]: + """The whole revision block: `at` is its TOP-left corner, the heading is + the first band and the revisions follow downwards in the order given.""" + prims: list[Prim] = list(revision_heading_prims(at=at, height=height)) + for index, row in enumerate(rows): + prims.extend(revision_row_prims(row, at=at, index=index, height=height)) + return tuple(prims) + + +async def read_revisions(backend: AutoCADBackend): + """Every revision row this server wrote, from its ACADMCP_SHEET_REV payload.""" + found: list[dict] = [] + for entity in await backend.entity_list(type_filter="TEXT"): + try: + raw = await backend.entity_get_xdata(entity.handle, REV_APP_ID) + payload = rev_payload(raw.get("xdata", {}).get(REV_APP_ID, [])) or {} + except (ValueError, KeyError, TypeError): + continue + if not payload.get("rev"): + continue + found.append( + { + "handle": entity.handle, + "rev": str(payload.get("rev", "")), + "description": str(payload.get("description", "")), + "date": str(payload.get("date", "")), + "by": str(payload.get("by", "")), + } + ) + found.sort(key=lambda row: row["rev"]) + return tuple(found) + + +async def add_revision( + backend: AutoCADBackend, + *, + rev: str, + description: str, + date: str = "", + by: str = "", + clouds=(), + tags=(), + size: str = "A3", + orientation: str = "landscape", + origin: Pt = (0.0, 0.0), + at: Pt | None = None, + cloud_segment: float = 8.0, + layout: str | None = None, +) -> dict: + """Append one revision row, and optionally cloud and tag what changed. + + Idempotent per revision code: a code the drawing already carries returns + `created=False` and draws nothing at all, so re-running a revision pass + cannot stack duplicate rows. Refusals fire before the first entity: a + malformed code, a cloud region that is not four finite numbers, and -- on + an engine whose `revcloud` capability is false -- any request for clouds. + """ + code = _check_rev(rev) + regions = [[float(v) for v in region] for region in clouds] + for index, region in enumerate(regions): + if len(region) != 4: + raise ValueError(f"clouds[{index}] must be [x0, y0, x1, y1]; got {clouds[index]!r}") + points = [[float(p[0]), float(p[1])] for p in tags] + if regions: + feature = backend.capabilities().features.get("revcloud") + if feature is None or not feature.supported: + from backends.base import UnsupportedCapabilityError + + raise UnsupportedCapabilityError( + "revcloud", + f"add_revision: the {backend.name} backend cannot draw revision clouds " + "(no ActiveX member; REVCLOUD is command-line only). Re-run without " + "`clouds` to record the row, or draw the sheet on the ezdxf engine.", + ) + + existing = await read_revisions(backend) + match = next((row for row in existing if row["rev"] == code), None) + if match is not None: + return { + "ok": True, + "created": False, + "rev": code, + "handle": match["handle"], + "row": len(existing), + "clouds": [], + "tags": [], + "layout": layout or "Model", + } + + index = len(existing) + new_row = {"rev": code, "description": description, "date": date, "by": by} + metrics = frame_metrics(size, orientation=orientation) + _x0, _y0, x1, y1 = metrics["frame"] + anchor = ( + (float(origin[0]) + x1 - LIST_WIDTH, float(origin[1]) + y1) + if at is None + else (float(at[0]), float(at[1])) + ) + # Append-only: the heading band is drawn once, on the first revision, and + # every later revision adds exactly one band below the last. Nothing is + # redrawn, so a revision can never stack a second copy of the block. + head_prims = list(revision_heading_prims(at=anchor)) if index == 0 else [] + row_prims = list(revision_row_prims(new_row, at=anchor, index=index)) + + previous = await enter_layout(backend, layout, caller="revision_add") + try: + block_handles = await draw_sheet_prims(backend, head_prims + row_prims) + cloud_handles = [] + for region in regions: + x0, y0, x1r, y1r = region + cloud = await backend.entity_create_revcloud( + [[x0, y0], [x1r, y0], [x1r, y1r], [x0, y1r]], + cloud_segment, + layer=SHEET_LAYER, + closed=True, + ) + cloud_handles.append(cloud.get("handle")) + # One entry per tag *asked for*, not per entity drawn: a tag is two + # primitives (the triangle and its letter), so a flat handle list would + # report twice as many tags as the caller requested. + tag_handles = [] + for point in points: + drawn = await draw_sheet_prims(backend, revision_tag_prims(code, (point[0], point[1]))) + tag_handles.append({"at": [point[0], point[1]], "handles": drawn}) + finally: + if layout is not None: + await backend.layout_set_current(previous) + + # The payload rides on the row band's first Text primitive, which is its + # REV cell -- `rev` is validated non-empty, so that cell is always drawn. + row_handles = block_handles[len(head_prims) :] + row_handle = next( + handle + for handle, prim in zip(row_handles, row_prims, strict=True) + if isinstance(prim, Text) + ) + await backend.entity_set_xdata( + row_handle, + REV_APP_ID, + rev_values( + { + "v": REV_PAYLOAD_VERSION, + "rev": code, + "description": description, + "date": date, + "by": by, + } + ), + ) + + return { + "ok": True, + "created": True, + "rev": code, + "handle": row_handle, + "row": index + 1, + "block_handles": block_handles, + "clouds": cloud_handles, + "tags": tag_handles, + "layout": layout or "Model", + "bbox": { + "min": [anchor[0], anchor[1] - ROW_HEIGHT * (index + 2)], + "max": [anchor[0] + LIST_WIDTH, anchor[1]], + }, + } diff --git a/server.py b/server.py index 82b37bc..649b981 100644 --- a/server.py +++ b/server.py @@ -8124,7 +8124,7 @@ async def drawing_properties_set( # --------------------------------------------------------------------------- -# ── SECTION 24: Sheet & Delivery (2 tools) ────────────────────────────────── +# ── SECTION 24: Sheet & Delivery (6 tools) ────────────────────────────────── # --------------------------------------------------------------------------- @@ -8295,6 +8295,260 @@ async def titleblock_apply( ) +@cad_tool( + summary="Append a revision row, with optional revision clouds and triangular tags.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Sheet: Add Revision", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def revision_add( + rev: Annotated[ + str, Field(description="Revision code: one to three capitals or digits (A, B, 01).") + ], + description: Annotated[str, Field(description="What changed, verbatim.")], + date: Annotated[str, Field(default="", description="Issue date, verbatim.")] = "", + by: Annotated[str, Field(default="", description="Who issued it.")] = "", + clouds: Annotated[ + list[list[float]] | None, + Field( + default=None, + description="Regions to cloud, each [x0, y0, x1, y1] in WCS. Refused before " + "anything is written on an engine without the revcloud capability.", + ), + ] = None, + tags: Annotated[ + list[list[float]] | None, + Field(default=None, description="Points [x, y] to put a triangular revision tag at."), + ] = None, + size: Annotated[ + str, Field(default="A3", description="Sheet the block is placed on: A4-A0.") + ] = "A3", + orientation: Annotated[str, Field(default="landscape")] = "landscape", + origin_x: Annotated[float, Field(default=0.0)] = 0.0, + origin_y: Annotated[float, Field(default=0.0)] = 0.0, + cloud_segment: Annotated[ + float, Field(default=8.0, gt=0, description="Revision-cloud arc segment length (mm).") + ] = 8.0, + layout: Annotated[str, Field(default="")] = "", + ctx: Context = None, +) -> dict: + """Append one row to the revision block, creating the block on first use. + + Idempotent per revision code: a code the drawing already carries returns + `created=false` and draws nothing, so re-running a revision pass cannot + stack duplicates. The block defaults to the upper-right corner of the + drawing frame, growing downwards, so it does not collide with the ISO 7573 + parts list above the title block. Refusals, all before the first entity: a + malformed code, a cloud region that is not [x0, y0, x1, y1], and any + request for clouds on a backend whose `revcloud` capability is false (the + COM engine — REVCLOUD has no ActiveX member) — that one carries + `capability: "revcloud"`. + """ + from engineering.sheet.revision import add_revision + + await ctx.info(f"Revision {rev}: {description}") + return await add_revision( + _backend(ctx), + rev=rev, + description=description, + date=date, + by=by, + clouds=clouds or (), + tags=tags or (), + size=size, + orientation=orientation, + origin=(origin_x, origin_y), + cloud_segment=cloud_segment, + layout=layout or None, + ) + + +@cad_tool( + summary="Read the bill of materials out of the drawing's block references.", + cost="read", +) +@mcp.tool( + annotations={"title": "Sheet: Extract Bill of Materials", "readOnlyHint": True}, + tags={"engineering", "sheet", "query"}, +) +async def bom_extract( + layer: Annotated[ + str, Field(default="", description="Only read INSERTs on this layer; empty reads all.") + ] = "", + group_by: Annotated[ + str, + Field( + default="designation", + description="Field to group identical parts by: designation, standard, material, " + "or '' for one row per insert.", + ), + ] = "designation", + columns: Annotated[ + list[str] | None, + Field( + default=None, + description="Columns to return; default item, qty, designation, standard, material.", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Walk the INSERTs and return the ISO 7573 item rows. Never modifies the + drawing. `bom_table` is what draws them. + + Each row's source is reported: `xdata` when the ACADMCP_MECH payload + supplied it (what `std_part_insert` writes), `attributes` when the block's + own ATTRIBs did. A block with neither a payload nor a DESIGNATION attribute + is skipped rather than guessed at, and `skipped` counts them. Refusals: an + unknown column or group_by field, named with the list of the ones there are. + """ + from engineering.sheet.bom import extract_records, rows_from_records + + backend = _backend(ctx) + records = await extract_records(backend, layer=layer or None) + rows = rows_from_records(records, columns=columns, group_by=group_by or None) + return { + "ok": True, + "standard": "ISO 7573", + "rows": [dict(row) | {"handles": list(row["handles"])} for row in rows], + "records": len(records), + "group_by": group_by or None, + } + + +@cad_tool(summary="Draw the parts list as a real TABLE above the title block.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Draw Parts List", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def bom_table( + rows: Annotated[ + list[dict] | None, + Field( + default=None, + description="Rows to draw; omit to extract them from the drawing first.", + ), + ] = None, + size: Annotated[str, Field(default="A3", description="Sheet the list is placed on.")] = "A3", + orientation: Annotated[str, Field(default="landscape")] = "landscape", + origin_x: Annotated[float, Field(default=0.0)] = 0.0, + origin_y: Annotated[float, Field(default=0.0)] = 0.0, + at_x: Annotated[ + float | None, + Field(default=None, description="Override the anchor X; default is the title block's."), + ] = None, + at_y: Annotated[float | None, Field(default=None)] = None, + direction: Annotated[ + str, + Field( + default="up", + description="'up' (ISO 7573 on a drawing: heading at the bottom, items ascending) " + "or 'down'.", + ), + ] = "up", + columns: Annotated[list[str] | None, Field(default=None)] = None, + row_height: Annotated[float, Field(default=7.0, gt=0)] = 7.0, + layout: Annotated[str, Field(default="")] = "", + ctx: Context = None, +) -> dict: + """Draw the ISO 7573 item list as a real TABLE entity, 180 mm wide (the + title-block width), growing upwards from directly above the title block. + + With `direction="up"` the heading row sits against the title block and item + 1 is the row above it, so the list extends upwards as parts are added — + which is what ISO 7573 asks for on a drawing. Pass `rows` to draw a list + you already have, or omit it and the tool runs `bom_extract` first. + `representation` reports what was drawn: `native` (a real ACAD_TABLE on the + live engine) or `composite` (rules and MTEXT headlessly, whose `handle` is + the first child). Refusals: no rows to draw, an unknown column, an unknown + direction, a `layout` that does not exist. + """ + from engineering.sheet.bom import ( + draw_bom_table, + extract_records, + rows_from_records, + ) + from engineering.sheet.titleblock import TB_HEIGHT, titleblock_origin + + backend = _backend(ctx) + if rows is None: + rows = list(rows_from_records(await extract_records(backend), columns=columns)) + tb_x, tb_y = titleblock_origin(size, orientation=orientation) + anchor = ( + origin_x + tb_x if at_x is None else at_x, + origin_y + tb_y + TB_HEIGHT if at_y is None else at_y, + ) + await ctx.info(f"Parts list: {len(rows)} rows at {anchor}") + return await draw_bom_table( + backend, + rows, + at=anchor, + direction=direction, + columns=columns, + row_height=row_height, + layout=layout or None, + ) + + +@cad_tool(summary="Add an ISO 6433 balloon linked to its item row.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Add Balloon", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def balloon_add( + item: Annotated[ + int, Field(ge=1, description="Item reference number, as bom_extract numbers it.") + ], + x: Annotated[float, Field(description="Balloon centre X (WCS).")], + y: Annotated[float, Field(description="Balloon centre Y (WCS).")], + leader_x: Annotated[float, Field(description="X of the dot on the item.")], + leader_y: Annotated[float, Field(description="Y of the dot on the item.")], + targets: Annotated[ + list[str] | None, + Field( + default=None, + description="Handles of the INSERTs this item is; the link a rerun renumbers by.", + ), + ] = None, + radius: Annotated[ + float, + Field( + default=4.0, + gt=0, + description="Balloon radius (mm). The numeral is 1.4x this, so a sheet " + "dimensioned at 3.5 mm wants 5.0 to satisfy ISO 6433.", + ), + ] = 4.0, + layer: Annotated[str, Field(default="DIM")] = "DIM", + layout: Annotated[str, Field(default="")] = "", + ctx: Context = None, +) -> dict: + """One ISO 6433 item reference: a numbered balloon, a leader, and a dot on + the item. + + The balloon carries an ACADMCP_MECH payload naming its targets, so running + the balloon pass again after the bill of materials is regrouped *renumbers* + the existing balloon (`renumbered: true`) instead of drawing a second one. + Refusals, all before the first entity: an item number below 1, a leader + target inside the balloon, and an item number already used by another + balloon for different targets — named with that balloon's handle. + """ + from engineering.sheet.bom import add_balloon + + await ctx.info(f"Balloon {item} at ({x}, {y})") + return await add_balloon( + _backend(ctx), + item=item, + at=(x, y), + leader_to=(leader_x, leader_y), + targets=tuple(targets or ()), + radius=radius, + layer=layer, + layout=layout or None, + ) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_sheet_bom.py b/tests/test_sheet_bom.py new file mode 100644 index 0000000..d52e250 --- /dev/null +++ b/tests/test_sheet_bom.py @@ -0,0 +1,296 @@ +"""ISO 7573 parts lists and ISO 6433 item references (balloons).""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import Circle, Line, Text +from engineering.sheet.bom import ( + COLUMN_LABELS, + DEFAULT_COLUMNS, + LIST_WIDTH, + ROW_HEIGHT, + balloon_prims, + rows_from_records, + table_layout, + table_prims, +) + +EPS = 1e-9 + + +def _records(): + return [ + { + "handle": "A1", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "material": "8.8", + "qty": 1, + }, + { + "handle": "A2", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "material": "8.8", + "qty": 1, + }, + { + "handle": "B1", + "designation": "SHAFT 40h6", + "standard": "", + "material": "C45", + "qty": 1, + }, + { + "handle": "C1", + "designation": "ISO 4032 - M12", + "standard": "ISO 4032", + "material": "8", + "qty": 1, + }, + ] + + +def test_default_columns_are_the_iso_7573_minimum_plus_material(): + assert DEFAULT_COLUMNS == ("item", "qty", "designation", "standard", "material") + for column in DEFAULT_COLUMNS: + assert column in COLUMN_LABELS + + +def test_rows_group_by_designation_sum_the_quantity_and_number_from_one(): + rows = rows_from_records(_records()) + assert [r["item"] for r in rows] == [1, 2, 3] + by_designation = {r["designation"]: r for r in rows} + assert by_designation["ISO 4014 - M12x60"]["qty"] == 2 + assert by_designation["ISO 4014 - M12x60"]["handles"] == ("A1", "A2") + assert by_designation["SHAFT 40h6"]["qty"] == 1 + assert set(rows[0]) >= set(DEFAULT_COLUMNS) | {"handles"} + + +def test_rows_are_ordered_deterministically_whatever_order_they_arrive_in(): + forward = rows_from_records(_records()) + backward = rows_from_records(list(reversed(_records()))) + assert [r["designation"] for r in forward] == [r["designation"] for r in backward] + assert [r["item"] for r in forward] == [r["item"] for r in backward] + + +def test_group_by_none_keeps_one_row_per_insert(): + rows = rows_from_records(_records(), group_by=None) + assert [r["item"] for r in rows] == [1, 2, 3, 4] + assert all(r["qty"] == 1 for r in rows) + + +def test_an_unknown_column_is_refused_with_the_list(): + with pytest.raises(ValueError) as excinfo: + rows_from_records(_records(), columns=("item", "colour")) + message = str(excinfo.value) + assert "colour" in message + assert "designation" in message + + +def test_the_list_is_the_title_block_width_and_the_default_shares_sum_to_it(): + assert LIST_WIDTH == 180.0 + assert ROW_HEIGHT == 7.0 + layout = table_layout(rows_from_records(_records()), at=(230.0, 70.0)) + assert layout["width"] == pytest.approx(180.0, abs=EPS) + assert sum(layout["widths"]) == pytest.approx(180.0, abs=EPS) + assert layout["height"] == pytest.approx(4 * 7.0, abs=EPS) # 3 items + the heading + + +def test_direction_up_puts_the_heading_at_the_bottom_and_item_1_above_it(): + rows = rows_from_records(_records()) + layout = table_layout(rows, at=(230.0, 70.0), direction="up") + assert layout["cells"][-1][0] == COLUMN_LABELS["item"] + assert layout["cells"][-2][0] == "1" + assert layout["cells"][0][0] == "3" + # the table is drawn downwards from its insertion point, so the insertion + # sits a full table height above the anchor and the list grows upwards + assert layout["insert"] == (230.0, 70.0 + 4 * 7.0) + + +def test_direction_down_puts_the_heading_at_the_top(): + rows = rows_from_records(_records()) + layout = table_layout(rows, at=(230.0, 200.0), direction="down") + assert layout["cells"][0][0] == COLUMN_LABELS["item"] + assert layout["cells"][1][0] == "1" + assert layout["insert"] == (230.0, 200.0) + + +def test_an_unknown_direction_is_refused(): + with pytest.raises(ValueError) as excinfo: + table_layout(rows_from_records(_records()), at=(0.0, 0.0), direction="sideways") + assert "sideways" in str(excinfo.value) + + +def test_table_prims_draw_the_same_rectangle_the_layout_declares(): + rows = rows_from_records(_records()) + layout = table_layout(rows, at=(230.0, 70.0)) + prims = table_prims(rows, at=(230.0, 70.0)) + xs = [p[0] for prim in prims if isinstance(prim, Line) for p in (prim.p1, prim.p2)] + ys = [p[1] for prim in prims if isinstance(prim, Line) for p in (prim.p1, prim.p2)] + assert min(xs) == pytest.approx(230.0, abs=EPS) + assert max(xs) == pytest.approx(230.0 + layout["width"], abs=EPS) + assert min(ys) == pytest.approx(70.0, abs=EPS) + assert max(ys) == pytest.approx(70.0 + layout["height"], abs=EPS) + assert any(isinstance(p, Text) and p.text == "ISO 4014 - M12x60" for p in prims) + + +def test_an_unknown_parts_list_standard_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + table_prims(rows_from_records(_records()), at=(0.0, 0.0), standard="ASME Y14.34") + message = str(excinfo.value) + assert "ASME Y14.34" in message + assert "ISO 7573" in message + + +def test_a_balloon_is_a_circle_a_leader_a_dot_and_the_numeral(): + prims = balloon_prims(7, (100.0, 100.0), (80.0, 90.0)) + circles = [p for p in prims if isinstance(p, Circle)] + lines = [p for p in prims if isinstance(p, Line)] + texts = [p for p in prims if isinstance(p, Text)] + assert len(circles) == 2 and len(lines) == 1 and len(texts) == 1 + balloon = max(circles, key=lambda c: c.radius) + dot = min(circles, key=lambda c: c.radius) + assert balloon.center == (100.0, 100.0) and balloon.radius == 4.0 + assert dot.center == (80.0, 90.0) + assert texts[0].text == "7" + # ISO 6433: the leader starts on the balloon's boundary and ends on the item + start_distance = ((lines[0].p1[0] - 100.0) ** 2 + (lines[0].p1[1] - 100.0) ** 2) ** 0.5 + assert start_distance == pytest.approx(4.0, abs=1e-9) + assert lines[0].p2 == (80.0, 90.0) + + +def test_a_leader_target_inside_the_balloon_is_refused(): + with pytest.raises(ValueError) as excinfo: + balloon_prims(3, (100.0, 100.0), (101.0, 100.0)) + assert "inside" in str(excinfo.value) + + +def test_a_non_positive_item_number_is_refused(): + with pytest.raises(ValueError) as excinfo: + balloon_prims(0, (0.0, 0.0), (20.0, 0.0)) + assert "0" in str(excinfo.value) + + +# ── reading and drawing ───────────────────────────────────────────────────── + +from engineering.mech.xdata import APP_ID, to_values # noqa: E402 +from engineering.sheet.bom import ( # noqa: E402 + add_balloon, + draw_bom_table, + extract_records, + read_balloons, +) + + +async def _place_bolt(backend, tag: str, x: float) -> str: + await backend.block_define( + "ISO4014_M12", + [{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0}], + [{"tag": "DESIGNATION", "x": 0.0, "y": -10.0, "height": 2.5, "default": ""}], + overwrite=True, + ) + insert = await backend.block_insert( + "ISO4014_M12", x, 0.0, attributes={"DESIGNATION": "ISO 4014 - M12x60"} + ) + await backend.entity_set_xdata( + insert.handle, + APP_ID, + to_values( + { + "v": 1, + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + ), + ) + return insert.handle + + +@pytest.mark.asyncio +async def test_extract_reads_the_xdata_payload_and_never_touches_the_drawing(backend): + handles = [await _place_bolt(backend, "a", 0.0), await _place_bolt(backend, "b", 50.0)] + before = len(await backend.entity_list()) + records = await extract_records(backend) + assert len(await backend.entity_list()) == before, "bom_extract must not modify the drawing" + assert {r["handle"] for r in records} == set(handles) + assert all(r["designation"] == "ISO 4014 - M12x60" for r in records) + assert all(r["standard"] == "ISO 4014" for r in records) + assert all(r["source"] == "xdata" for r in records) + + +@pytest.mark.asyncio +async def test_extract_falls_back_to_the_block_attributes_when_there_is_no_payload(backend): + await backend.block_define( + "PLATE", + [{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 3.0}], + [{"tag": "DESIGNATION", "x": 0.0, "y": -6.0, "height": 2.5, "default": ""}], + overwrite=True, + ) + await backend.block_insert("PLATE", 10.0, 10.0, attributes={"DESIGNATION": "PLATE 10"}) + records = await extract_records(backend) + plate = next(r for r in records if r["designation"] == "PLATE 10") + assert plate["source"] == "attributes" + assert plate["standard"] == "" + + +@pytest.mark.asyncio +async def test_the_table_is_a_real_table_entity_sitting_on_its_anchor(backend): + await _place_bolt(backend, "a", 0.0) + rows = rows_from_records(await extract_records(backend)) + result = await draw_bom_table(backend, rows, at=(230.0, 70.0), direction="up") + assert result["ok"] is True + assert result["rows"] == 1 + assert result["insert"] == (230.0, 70.0 + 2 * 7.0) + assert result["bbox"] == {"min": [230.0, 70.0], "max": [410.0, 84.0]} + # Measured: `entity_create_table` returns EntityInfo(type="TABLE") whose + # `handle` is the FIRST CHILD of a composite on the headless engine -- so + # `entity_get(handle)` reads back a rule LINE, never an ACAD_TABLE. The + # contract is pinned by tests/test_table_leader.py; `representation` says + # which of the two renderings a caller got. + assert result["entity_type"] == "TABLE" + assert result["representation"] == "composite" + assert result["handle"] in result["child_handles"] + table = await backend.entity_get(result["handle"]) + assert table.layer == "TEXT" + + +@pytest.mark.asyncio +async def test_a_balloon_records_its_targets_and_a_rerun_renumbers_instead_of_duplicating(backend): + target = await _place_bolt(backend, "a", 0.0) + first = await add_balloon( + backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(target,) + ) + assert first["renumbered"] is False + circles_after_first = len(await backend.entity_list(type_filter="CIRCLE")) + + second = await add_balloon( + backend, item=5, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(target,) + ) + assert second["renumbered"] is True + assert second["handle"] == first["handle"] + assert len(await backend.entity_list(type_filter="CIRCLE")) == circles_after_first + + balloons = await read_balloons(backend) + assert len(balloons) == 1 + assert balloons[0]["item"] == 5 + assert balloons[0]["targets"] == [target] + numeral = await backend.entity_get(balloons[0]["text"]) + assert numeral.properties["text"] == "5" + + +@pytest.mark.asyncio +async def test_reusing_an_item_number_for_different_targets_is_refused(backend): + one = await _place_bolt(backend, "a", 0.0) + two = await _place_bolt(backend, "b", 60.0) + await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(one,)) + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await add_balloon(backend, item=1, at=(100.0, 40.0), leader_to=(60.0, 0.0), targets=(two,)) + assert "1" in str(excinfo.value) + assert len(await backend.entity_list()) == before diff --git a/tests/test_sheet_revision.py b/tests/test_sheet_revision.py new file mode 100644 index 0000000..a13eaa6 --- /dev/null +++ b/tests/test_sheet_revision.py @@ -0,0 +1,148 @@ +"""Revision rows, revision clouds and the triangular revision tags.""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import Poly, Text +from engineering.sheet.revision import ( + REVISION_COLUMNS, + REVISION_LABELS, + TAG_SIDE, + add_revision, + read_revisions, + revision_block_prims, + revision_heading_prims, + revision_row_prims, + revision_tag_prims, +) + +EPS = 1e-9 + + +def test_the_revision_columns_are_the_iso_7200_version_fields(): + assert REVISION_COLUMNS == ("rev", "description", "date", "by") + for column in REVISION_COLUMNS: + assert column in REVISION_LABELS + + +def test_a_tag_is_an_equilateral_triangle_carrying_the_letter(): + prims = revision_tag_prims("B", (100.0, 200.0)) + triangle = next(p for p in prims if isinstance(p, Poly)) + text = next(p for p in prims if isinstance(p, Text)) + assert triangle.closed and len(triangle.points) == 3 + xs = [p[0] for p in triangle.points] + assert max(xs) - min(xs) == pytest.approx(TAG_SIDE, abs=EPS) + assert text.text == "B" + + +def test_the_block_is_180_mm_wide_and_grows_downwards_from_its_anchor(): + rows = [ + {"rev": "A", "description": "first issue", "date": "2026-01-05", "by": "UC"}, + {"rev": "B", "description": "bore 40H7", "date": "2026-03-11", "by": "UC"}, + ] + prims = revision_block_prims(rows, at=(230.0, 287.0)) + xs = [c[0] for p in prims if isinstance(p, Poly) for c in p.points] + xs += [v for p in prims if hasattr(p, "p1") for v in (p.p1[0], p.p2[0])] + assert min(xs) == pytest.approx(230.0, abs=EPS) + assert max(xs) == pytest.approx(410.0, abs=EPS) + ys = [v for p in prims if hasattr(p, "p1") for v in (p.p1[1], p.p2[1])] + assert max(ys) == pytest.approx(287.0, abs=EPS) + assert min(ys) == pytest.approx(287.0 - 3 * 7.0, abs=EPS) # heading + 2 rows + + +def test_the_block_built_row_by_row_is_the_block_built_at_once(): + """`add_revision` appends one band at a time; the whole-block builder is + what the tests read. They must be the same geometry or the second revision + would not line up with the first.""" + rows = [ + {"rev": "A", "description": "first issue", "date": "2026-01-05", "by": "UC"}, + {"rev": "B", "description": "bore 40H7", "date": "2026-03-11", "by": "UC"}, + ] + at = (230.0, 287.0) + incremental = list(revision_heading_prims(at=at)) + for index, row in enumerate(rows): + incremental.extend(revision_row_prims(row, at=at, index=index)) + assert tuple(incremental) == revision_block_prims(rows, at=at) + + +def test_a_rows_first_text_primitive_is_its_rev_cell(): + """`add_revision` hangs the payload on that primitive, so this is the link + between the drawn row and the row a reader gets back.""" + band = revision_row_prims( + {"rev": "B", "description": "", "date": "", "by": ""}, at=(230.0, 287.0), index=0 + ) + first_text = next(p for p in band if isinstance(p, Text)) + assert first_text.text == "B" + + +@pytest.mark.parametrize("bad", ["", "revision b", "AB CD", "b!"]) +def test_a_malformed_revision_code_is_refused(bad): + with pytest.raises(ValueError) as excinfo: + revision_tag_prims(bad, (0.0, 0.0)) + assert "revision" in str(excinfo.value).lower() + + +@pytest.mark.asyncio +async def test_a_revision_row_is_written_once_per_letter(backend): + first = await add_revision( + backend, rev="B", description="bore 40H7", date="2026-03-11", by="UC" + ) + assert first["created"] is True + count = len(await backend.entity_list()) + again = await add_revision( + backend, rev="B", description="bore 40H7 (again)", date="2026-03-12", by="UC" + ) + assert again["created"] is False + assert again["handle"] == first["handle"] + assert len(await backend.entity_list()) == count, "a repeated revision must draw nothing" + rows = await read_revisions(backend) + assert [r["rev"] for r in rows] == ["B"] + assert rows[0]["description"] == "bore 40H7" + + +@pytest.mark.asyncio +async def test_two_revisions_stack_and_read_back_in_order(backend): + await add_revision(backend, rev="A", description="first issue", date="2026-01-05", by="UC") + await add_revision(backend, rev="B", description="bore 40H7", date="2026-03-11", by="UC") + rows = await read_revisions(backend) + assert [r["rev"] for r in rows] == ["A", "B"] + + +@pytest.mark.asyncio +async def test_clouds_and_tags_are_drawn_when_asked(backend): + result = await add_revision( + backend, + rev="C", + description="slot widened", + clouds=[[10.0, 10.0, 60.0, 40.0]], + tags=[[65.0, 45.0]], + ) + assert len(result["clouds"]) == 1 + assert len(result["tags"]) == 1 + tag_texts = [ + e for e in await backend.entity_list(type_filter="TEXT") if e.properties["text"] == "C" + ] + assert tag_texts + + +@pytest.mark.asyncio +async def test_clouds_are_refused_before_any_row_when_the_engine_has_no_revcloud( + backend, monkeypatch +): + from backends.base import CapabilityMap, FeatureCapability + + original = backend.capabilities() + features = dict(original.features) + features["revcloud"] = FeatureCapability(False, reason="no_activex_member_command_only") + monkeypatch.setattr( + backend, "capabilities", lambda: CapabilityMap(backend=original.backend, features=features) + ) + before = len(await backend.entity_list()) + from backends.base import UnsupportedCapabilityError + + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await add_revision(backend, rev="D", description="x", clouds=[[0.0, 0.0, 10.0, 10.0]]) + assert excinfo.value.capability == "revcloud" + assert len(await backend.entity_list()) == before + assert (await read_revisions(backend)) == () diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index ebe17b7..b1cff77 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 (210 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 @@ -534,6 +534,9 @@ async def test_tool_group_sizes_are_unchanged(): `sheet`, and `sheet` is not in `_GROUP_TAG_PRIORITY`, so they file under `engineering` beside `titleblock_apply_iso_a3` rather than opening a 24th group. + It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and + `balloon_add` joined the same section; `bom_extract` also carries `query`, + but `engineering` outranks it in `_GROUP_TAG_PRIORITY`. Every other number here has been unchanged since the snapshot was taken. """ @@ -545,7 +548,7 @@ async def test_tool_group_sizes_are_unchanged(): "corner_ops": 4, "dimensions": 5, "drawing": 11, - "engineering": 12, + "engineering": 16, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, @@ -563,4 +566,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 206 + assert sum(sizes.values()) == 210 From de8dc8f81312c37ef8456e68cec6fa34affcb9e5 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 22:39:10 +0300 Subject: [PATCH 5/7] fix(task-13): read revision/balloon state from the space it is drawn on, and never at 200 Three findings, one family: a guard that reads the WRONG SET silently stops guarding and the drawing gets a duplicate or a wrong number with no refusal. 1. Layout-scoped state was read from the wrong space. add_revision called read_revisions and add_balloon called read_balloons BEFORE enter_layout; both read entity_list, which follows the current space, so the guard inspected Model while the rows and balloons lived on the layout -- and the layout is the sheet workflow (sheet_frame and titleblock_apply both take one). Measured headlessly: two revision_add(rev="B", layout="SHEET1") calls both returned created=True, handles 46 and 5A, both bbox {min [230,273], max [410,287]} -- a second heading band and a second B row drawn exactly on top of the first, twelve TEXTs on the tab instead of six. Both reads now happen inside the entered layout, and the whole body sits in the try/ so the caller's tab comes back on the refusal path too. 2. bom_extract truncated the parts list at 200 INSERTs and printed a wrong QTY into a real TABLE. extract_records passed its own limit=200 default straight to entity_list, the tool exposed no limit, and nothing in the payload said the list had been cut: 250 bolts reported [("ISO 4014 - M12x60", 200)]. all_entities now pages entity_list (offset loop, PAGE_SIZE 1000, plus a no-new-handles guard so a backend that ignored offset cannot spin) and extract_records(limit=None) reads the whole drawing. bom_extract exposes limit (0 = no cap) and reports truncated measured, by asking for one record past the cap. 3. The balloon clash guard did not fire in two routine cases. (a) targets is optional in the tool, so the clash test compared () != () -- False -- and two untargeted balloons carried item 1 with no refusal. The renumber arm above has already returned for the balloon that stands for these targets, so anything still carrying the number is a clash, whatever its targets; the message names the handle and says "no targets" when it has none. (b) read_balloons and read_revisions took entity_list's 200-entity default, so a drawing whose first 200 CIRCLEs are a hole pattern read back no balloons at all and every guard passed blind -- and index = len(existing) restarted at 0, stacking revision bands. Both readers now go through all_entities. Evidence, headless (ezdxf), each finding's own repro before -> after: 1 created True/True, handles 46+5A, 12 TEXTs -> created True/False, one handle 46, 6 TEXTs; renumbered False/False -> False/True, same handle 33 2 records 200, rows [(..., 200)] -> records 250, rows [(..., 250)] 3a two balloons handles 2F+35, no refusal -> refused, naming balloon 2F 3b read_balloons() == () with 206 circles -> the balloon past the 200th circle is found, the rerun renumbers, the item-1/BBB clash is refused 3c 200 notes + rev A twice + rev B -> created True/False/True, row 1 then row 2 (was: every band at row 1, on top of the last) Six regression tests added: one per finding plus the layout-scoped twin of each, which is what would have caught all three. The live engine was UNAVAILABLE for this round: win32com.client.GetActiveObject("AutoCAD.Application") did not return within 90 s against the running acad.exe (PID 40068), and the ComBackend probe timed out on its first call at both COM_CALL_TIMEOUT=60 and 180. No second AutoCAD was launched and no document was opened. No new ActiveX call is introduced by this fix -- it only reorders existing calls and pages entity_list(offset=), which both engines already implement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/sheet/bom.py | 178 +++++++++++++++++++++++----------- engineering/sheet/revision.py | 153 ++++++++++++++++------------- server.py | 31 +++++- tests/test_sheet_bom.py | 93 ++++++++++++++++++ tests/test_sheet_revision.py | 37 +++++++ 5 files changed, 363 insertions(+), 129 deletions(-) diff --git a/engineering/sheet/bom.py b/engineering/sheet/bom.py index 073bdfd..2bca393 100644 --- a/engineering/sheet/bom.py +++ b/engineering/sheet/bom.py @@ -49,8 +49,10 @@ __all__ = [ "DIRECTIONS", "LIST_STANDARDS", "LIST_WIDTH", + "PAGE_SIZE", "ROW_HEIGHT", "add_balloon", + "all_entities", "balloon_prims", "draw_bom_table", "extract_records", @@ -104,6 +106,43 @@ COLUMN_SHARES: dict[str, float] = { } +#: How many entities one `entity_list` call asks for while paging. It is a page +#: size, never a cap: `all_entities` keeps asking until a short page comes back. +PAGE_SIZE = 1000 + + +async def all_entities(backend: AutoCADBackend, *, type_filter=None, layer=None): + """Every entity of a type in the CURRENT space -- not the first 200 of them. + + `entity_list`'s `limit` defaults to 200 on both engines, so a single call is + a silent truncation on any real drawing: a parts list that stops at the + 200th INSERT prints a wrong QTY, and a duplicate-balloon guard that only + sees the first 200 CIRCLEs is blind on a drawing whose hole pattern fills + them. Everything in this module that *counts* or *guards* reads through + here instead. + + The loop stops on a short page, and also when a page brings nothing new -- + a backend that ignored `offset` would otherwise hand back the same page for + ever. Handles are unique per drawing, so that guard cannot drop a real + entity. + """ + found: list = [] + seen: set[str] = set() + offset = 0 + while True: + page = await backend.entity_list( + type_filter=type_filter, layer_filter=layer, limit=PAGE_SIZE, offset=offset + ) + fresh = [entity for entity in page if entity.handle not in seen] + if not fresh: + return tuple(found) + seen.update(entity.handle for entity in fresh) + found.extend(fresh) + if len(page) < PAGE_SIZE: + return tuple(found) + offset += len(page) + + def _check_columns(columns) -> tuple[str, ...]: chosen = tuple(DEFAULT_COLUMNS if columns is None else columns) unknown = [c for c in chosen if c not in BOM_COLUMNS] @@ -304,7 +343,7 @@ def _from_attributes(attributes: dict) -> dict: return record -async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int = 200): +async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int | None = None): """Walk the INSERTs and return one parts-list record per block reference. Reads only: the entity listing, each INSERT's attributes and its @@ -312,8 +351,17 @@ async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int = 2 is not ours. `source` is "xdata" when the payload supplied the row and "attributes" when the block's ATTRIBs did; a block with neither is skipped rather than guessed at. + + `limit` is None by default and the whole drawing is read, by paging + `entity_list` rather than taking its 200-entity default: a parts list that + stops at the 200th INSERT does not print a shorter list, it prints a wrong + QTY, and nothing on the sheet says so. A caller that wants a cap passes one + and reads `len(records)` back against it -- `bom_extract` asks for one more + record than the cap so it can report `truncated` exactly. """ - inserts = await backend.entity_list(type_filter="INSERT", layer_filter=layer, limit=limit) + if limit is not None and int(limit) <= 0: + raise ValueError(f"bom_extract: limit must be a positive number of records; got {limit!r}") + inserts = await all_entities(backend, type_filter="INSERT", layer=layer) records: list[dict] = [] for entity in inserts: payload: dict = {} @@ -353,6 +401,8 @@ async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int = 2 "source": "xdata" if payload else "attributes", } ) + if limit is not None and len(records) >= int(limit): + break return tuple(records) @@ -415,9 +465,15 @@ async def draw_bom_table( async def read_balloons(backend: AutoCADBackend): - """Every ISO 6433 balloon this server drew, from its ACADMCP_MECH payload.""" + """Every ISO 6433 balloon this server drew in the CURRENT space, from its + ACADMCP_MECH payload. + + Paged, not capped: a drawing whose first 200 CIRCLEs are a hole pattern -- + routine -- used to read back no balloons at all, which made every guard in + `add_balloon` silently pass. + """ found: list[dict] = [] - for entity in await backend.entity_list(type_filter="CIRCLE"): + for entity in await all_entities(backend, type_filter="CIRCLE"): try: raw = await backend.entity_get_xdata(entity.handle, APP_ID) payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {} @@ -453,69 +509,79 @@ async def add_balloon( A balloon already pointing at the same targets is *renumbered* -- its numeral is edited and its payload rewritten -- so running the balloon pass again after the parts list is regrouped does not litter the sheet with - duplicates. An item number already used for a different set of targets is - refused before anything is drawn: ISO 129-1 and ISO 6433 both want one + duplicates. An item number already on the sheet is refused before anything + is drawn unless it is that renumber: ISO 129-1 and ISO 6433 both want one reference per item. + + Both guards read the space the balloon is going ON. `read_balloons` lists + the current space, so reading it before `enter_layout` inspected Model + while the balloons lived on the layout -- and the layout is the sheet + workflow, so the guards were off exactly where they matter. """ number = int(item) wanted = tuple(sorted(str(t) for t in targets)) prims = balloon_prims(number, at, leader_to, radius=radius) # validates first - existing = await read_balloons(backend) - for balloon in existing: - if tuple(sorted(balloon["targets"])) == wanted and wanted: - if balloon["text"]: - await backend.entity_edit_text(balloon["text"], text=str(number)) - payload = { - "v": PAYLOAD_VERSION, - "kind": "balloon", - "item": number, - "targets": list(wanted), - "text": balloon["text"], - } - await backend.entity_set_xdata(balloon["handle"], APP_ID, to_values(payload)) - return { - "ok": True, - "renumbered": True, - "item": number, - "handle": balloon["handle"], - "text": balloon["text"], - "targets": list(wanted), - "layout": layout or "Model", - } - clash = next( - (b for b in existing if b["item"] == number and tuple(sorted(b["targets"])) != wanted), - None, - ) - if clash is not None: - raise ValueError( - f"item reference {number} is already used by balloon {clash['handle']} for " - f"targets {clash['targets']}; ISO 6433 gives each item one reference" - ) previous = await enter_layout(backend, layout, caller="balloon_add") try: + existing = await read_balloons(backend) + for balloon in existing: + if wanted and tuple(sorted(balloon["targets"])) == wanted: + if balloon["text"]: + await backend.entity_edit_text(balloon["text"], text=str(number)) + payload = { + "v": PAYLOAD_VERSION, + "kind": "balloon", + "item": number, + "targets": list(wanted), + "text": balloon["text"], + } + await backend.entity_set_xdata(balloon["handle"], APP_ID, to_values(payload)) + return { + "ok": True, + "renumbered": True, + "item": number, + "handle": balloon["handle"], + "text": balloon["text"], + "targets": list(wanted), + "layout": layout or "Model", + } + # Anything still carrying this number is a clash: the renumber arm above + # has already returned for the one balloon that stands for these + # targets. Comparing the target tuples here instead let two untargeted + # balloons -- `targets` is optional in the tool, so `() != ()` -- carry + # the same item number with no refusal at all. + clash = next((b for b in existing if b["item"] == number), None) + if clash is not None: + has_targets = f"targets {clash['targets']}" if clash["targets"] else "no targets" + raise ValueError( + f"item reference {number} is already used by balloon {clash['handle']} " + f"({has_targets}); ISO 6433 gives each item one reference. Pass that " + "balloon's targets to renumber it in place, or use a free number." + ) + handles = await draw_sheet_prims( backend, prims, layer_map={"visible": layer, "text": layer, "center": layer} ) + payload = { + "v": PAYLOAD_VERSION, + "kind": "balloon", + "item": number, + "targets": list(wanted), + "text": handles[3], + } + await backend.entity_set_xdata(handles[0], APP_ID, to_values(payload)) + return { + "ok": True, + "renumbered": False, + "item": number, + "handle": handles[0], + "leader": handles[1], + "dot": handles[2], + "text": handles[3], + "targets": list(wanted), + "layout": layout or "Model", + } finally: if layout is not None: await backend.layout_set_current(previous) - payload = { - "v": PAYLOAD_VERSION, - "kind": "balloon", - "item": number, - "targets": list(wanted), - "text": handles[3], - } - await backend.entity_set_xdata(handles[0], APP_ID, to_values(payload)) - return { - "ok": True, - "renumbered": False, - "item": number, - "handle": handles[0], - "leader": handles[1], - "dot": handles[2], - "text": handles[3], - "targets": list(wanted), - "layout": layout or "Model", - } diff --git a/engineering/sheet/revision.py b/engineering/sheet/revision.py index cd2a748..005add9 100644 --- a/engineering/sheet/revision.py +++ b/engineering/sheet/revision.py @@ -29,7 +29,7 @@ from typing import TYPE_CHECKING from backends.xdata_specs import MAX_BYTES, app_size from engineering.mech.primitives import Line, Poly, Prim, Pt, Text -from engineering.sheet.bom import LIST_WIDTH, ROW_HEIGHT, TEXT_HEIGHT +from engineering.sheet.bom import LIST_WIDTH, ROW_HEIGHT, TEXT_HEIGHT, all_entities from engineering.sheet.frames import ( SHEET_LAYER, draw_sheet_prims, @@ -226,9 +226,16 @@ def revision_block_prims(rows, *, at: Pt, height: float = ROW_HEIGHT) -> tuple[P async def read_revisions(backend: AutoCADBackend): - """Every revision row this server wrote, from its ACADMCP_SHEET_REV payload.""" + """Every revision row this server wrote in the CURRENT space, from its + ACADMCP_SHEET_REV payload. + + Paged, not capped: `entity_list`'s 200-entity default made a lettered + drawing read back no revisions at all, which both broke the idempotency + guard and restarted `index = len(existing)` at 0 -- a second heading band + and a second row drawn exactly on top of the first. + """ found: list[dict] = [] - for entity in await backend.entity_list(type_filter="TEXT"): + for entity in await all_entities(backend, type_filter="TEXT"): try: raw = await backend.entity_get_xdata(entity.handle, REV_APP_ID) payload = rev_payload(raw.get("xdata", {}).get(REV_APP_ID, [])) or {} @@ -272,6 +279,12 @@ async def add_revision( cannot stack duplicate rows. Refusals fire before the first entity: a malformed code, a cloud region that is not four finite numbers, and -- on an engine whose `revcloud` capability is false -- any request for clouds. + + The idempotency guard reads the space the row is going ON. `read_revisions` + lists the current space, so reading it before `enter_layout` inspected + Model while the block lived on the layout, and every re-run of a + `layout=`-scoped revision pass stacked a second heading band and a second + row exactly on top of the first. """ code = _check_rev(rev) regions = [[float(v) for v in region] for region in clouds] @@ -291,37 +304,37 @@ async def add_revision( "`clouds` to record the row, or draw the sheet on the ezdxf engine.", ) - existing = await read_revisions(backend) - match = next((row for row in existing if row["rev"] == code), None) - if match is not None: - return { - "ok": True, - "created": False, - "rev": code, - "handle": match["handle"], - "row": len(existing), - "clouds": [], - "tags": [], - "layout": layout or "Model", - } - - index = len(existing) - new_row = {"rev": code, "description": description, "date": date, "by": by} - metrics = frame_metrics(size, orientation=orientation) - _x0, _y0, x1, y1 = metrics["frame"] - anchor = ( - (float(origin[0]) + x1 - LIST_WIDTH, float(origin[1]) + y1) - if at is None - else (float(at[0]), float(at[1])) - ) - # Append-only: the heading band is drawn once, on the first revision, and - # every later revision adds exactly one band below the last. Nothing is - # redrawn, so a revision can never stack a second copy of the block. - head_prims = list(revision_heading_prims(at=anchor)) if index == 0 else [] - row_prims = list(revision_row_prims(new_row, at=anchor, index=index)) - previous = await enter_layout(backend, layout, caller="revision_add") try: + existing = await read_revisions(backend) + match = next((row for row in existing if row["rev"] == code), None) + if match is not None: + return { + "ok": True, + "created": False, + "rev": code, + "handle": match["handle"], + "row": len(existing), + "clouds": [], + "tags": [], + "layout": layout or "Model", + } + + index = len(existing) + new_row = {"rev": code, "description": description, "date": date, "by": by} + metrics = frame_metrics(size, orientation=orientation) + _x0, _y0, x1, y1 = metrics["frame"] + anchor = ( + (float(origin[0]) + x1 - LIST_WIDTH, float(origin[1]) + y1) + if at is None + else (float(at[0]), float(at[1])) + ) + # Append-only: the heading band is drawn once, on the first revision, and + # every later revision adds exactly one band below the last. Nothing is + # redrawn, so a revision can never stack a second copy of the block. + head_prims = list(revision_heading_prims(at=anchor)) if index == 0 else [] + row_prims = list(revision_row_prims(new_row, at=anchor, index=index)) + block_handles = await draw_sheet_prims(backend, head_prims + row_prims) cloud_handles = [] for region in regions: @@ -340,44 +353,44 @@ async def add_revision( for point in points: drawn = await draw_sheet_prims(backend, revision_tag_prims(code, (point[0], point[1]))) tag_handles.append({"at": [point[0], point[1]], "handles": drawn}) + + # The payload rides on the row band's first Text primitive, which is its + # REV cell -- `rev` is validated non-empty, so that cell is always drawn. + row_handles = block_handles[len(head_prims) :] + row_handle = next( + handle + for handle, prim in zip(row_handles, row_prims, strict=True) + if isinstance(prim, Text) + ) + await backend.entity_set_xdata( + row_handle, + REV_APP_ID, + rev_values( + { + "v": REV_PAYLOAD_VERSION, + "rev": code, + "description": description, + "date": date, + "by": by, + } + ), + ) + + return { + "ok": True, + "created": True, + "rev": code, + "handle": row_handle, + "row": index + 1, + "block_handles": block_handles, + "clouds": cloud_handles, + "tags": tag_handles, + "layout": layout or "Model", + "bbox": { + "min": [anchor[0], anchor[1] - ROW_HEIGHT * (index + 2)], + "max": [anchor[0] + LIST_WIDTH, anchor[1]], + }, + } finally: if layout is not None: await backend.layout_set_current(previous) - - # The payload rides on the row band's first Text primitive, which is its - # REV cell -- `rev` is validated non-empty, so that cell is always drawn. - row_handles = block_handles[len(head_prims) :] - row_handle = next( - handle - for handle, prim in zip(row_handles, row_prims, strict=True) - if isinstance(prim, Text) - ) - await backend.entity_set_xdata( - row_handle, - REV_APP_ID, - rev_values( - { - "v": REV_PAYLOAD_VERSION, - "rev": code, - "description": description, - "date": date, - "by": by, - } - ), - ) - - return { - "ok": True, - "created": True, - "rev": code, - "handle": row_handle, - "row": index + 1, - "block_handles": block_handles, - "clouds": cloud_handles, - "tags": tag_handles, - "layout": layout or "Model", - "bbox": { - "min": [anchor[0], anchor[1] - ROW_HEIGHT * (index + 2)], - "max": [anchor[0] + LIST_WIDTH, anchor[1]], - }, - } diff --git a/server.py b/server.py index 649b981..4f52b46 100644 --- a/server.py +++ b/server.py @@ -8392,6 +8392,15 @@ async def bom_extract( description="Columns to return; default item, qty, designation, standard, material.", ), ] = None, + limit: Annotated[ + int, + Field( + default=0, + ge=0, + description="Stop after this many item records; 0 (the default) reads the whole " + "drawing. `truncated` says whether the cap cut the list.", + ), + ] = 0, ctx: Context = None, ) -> dict: """Walk the INSERTs and return the ISO 7573 item rows. Never modifies the @@ -8400,19 +8409,35 @@ async def bom_extract( Each row's source is reported: `xdata` when the ACADMCP_MECH payload supplied it (what `std_part_insert` writes), `attributes` when the block's own ATTRIBs did. A block with neither a payload nor a DESIGNATION attribute - is skipped rather than guessed at, and `skipped` counts them. Refusals: an - unknown column or group_by field, named with the list of the ones there are. + is skipped rather than guessed at, and `skipped` counts them. + + The whole drawing is read by default — `entity_list`'s 200-entity page is + paged through, not taken as a cap, because an item list that stops at the + 200th INSERT prints a wrong QTY rather than a shorter one. Pass `limit` to + cap it deliberately; `truncated` is then true if and only if the cap cut + the list. Refusals: an unknown column or group_by field, named with the + list of the ones there are. """ from engineering.sheet.bom import extract_records, rows_from_records backend = _backend(ctx) - records = await extract_records(backend, layer=layer or None) + cap = int(limit) or None + # One record past the cap, so `truncated` is measured rather than guessed: + # exactly `cap` records on the drawing is not a truncation. + records = await extract_records( + backend, layer=layer or None, limit=None if cap is None else cap + 1 + ) + truncated = cap is not None and len(records) > cap + if truncated: + records = records[:cap] rows = rows_from_records(records, columns=columns, group_by=group_by or None) return { "ok": True, "standard": "ISO 7573", "rows": [dict(row) | {"handles": list(row["handles"])} for row in rows], "records": len(records), + "limit": cap, + "truncated": truncated, "group_by": group_by or None, } diff --git a/tests/test_sheet_bom.py b/tests/test_sheet_bom.py index d52e250..bf95ada 100644 --- a/tests/test_sheet_bom.py +++ b/tests/test_sheet_bom.py @@ -294,3 +294,96 @@ async def test_reusing_an_item_number_for_different_targets_is_refused(backend): await add_balloon(backend, item=1, at=(100.0, 40.0), leader_to=(60.0, 0.0), targets=(two,)) assert "1" in str(excinfo.value) assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_a_second_balloon_with_the_same_number_and_no_targets_is_refused(backend): + """`targets` is optional in the tool, so the clash test used to compare + `() != ()` -- False -- and never fire. Two balloons carrying item 1.""" + first = await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0)) + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await add_balloon(backend, item=1, at=(90.0, 40.0), leader_to=(60.0, 0.0)) + assert first["handle"] in str(excinfo.value) + assert len(await backend.entity_list()) == before + assert [b["item"] for b in await read_balloons(backend)] == [1] + + +@pytest.mark.asyncio +async def test_the_balloon_guards_read_past_the_entity_list_page(backend): + """A routine hole pattern fills `entity_list`'s 200-entity default, which + used to make `read_balloons` return nothing and every guard pass.""" + for index in range(200): + await backend.entity_create_circle(float(index) * 10.0, 0.0, 2.0) + await add_balloon(backend, item=1, at=(40.0, 400.0), leader_to=(0.0, 380.0), targets=("AAA",)) + assert [b["item"] for b in await read_balloons(backend)] == [1] + again = await add_balloon( + backend, item=9, at=(40.0, 400.0), leader_to=(0.0, 380.0), targets=("AAA",) + ) + assert again["renumbered"] is True, "the guard must see a balloon past the 200th circle" + with pytest.raises(ValueError): + await add_balloon( + backend, item=9, at=(200.0, 400.0), leader_to=(160.0, 380.0), targets=("BBB",) + ) + + +@pytest.mark.asyncio +async def test_extract_reads_every_insert_not_the_first_page(backend): + """250 bolts must report QTY 250. The 200-entity `entity_list` default used + to print 200 into a real TABLE with nothing saying it had been cut.""" + await backend.block_define("BOLT", [{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 3.0}], []) + for index in range(250): + insert = await backend.block_insert("BOLT", float(index) * 10.0, 0.0) + await backend.entity_set_xdata( + insert.handle, + APP_ID, + to_values( + { + "v": 1, + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + ), + ) + records = await extract_records(backend) + assert len(records) == 250 + rows = rows_from_records(records) + assert [(r["designation"], r["qty"]) for r in rows] == [("ISO 4014 - M12x60", 250)] + # A cap is still honoured when one is asked for, explicitly. + assert len(await extract_records(backend, limit=17)) == 17 + with pytest.raises(ValueError): + await extract_records(backend, limit=0) + + +@pytest.mark.asyncio +async def test_the_balloon_guards_read_the_layout_they_draw_on(backend): + """`read_balloons` follows the current space, so reading it before + `enter_layout` inspected Model while the balloons went on the layout.""" + await backend.layout_create("SHEET1") + first = await add_balloon( + backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=("AAA",), layout="SHEET1" + ) + again = await add_balloon( + backend, item=5, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=("AAA",), layout="SHEET1" + ) + assert again["renumbered"] is True + assert again["handle"] == first["handle"] + with pytest.raises(ValueError): + await add_balloon( + backend, + item=5, + at=(90.0, 40.0), + leader_to=(60.0, 0.0), + targets=("BBB",), + layout="SHEET1", + ) + assert (await backend.layout_list())["current"] == "Model", "the caller's tab must come back" + await backend.layout_set_current("SHEET1") + try: + assert len(await backend.entity_list(type_filter="CIRCLE", limit=10_000)) == 2 + finally: + await backend.layout_set_current("Model") diff --git a/tests/test_sheet_revision.py b/tests/test_sheet_revision.py index a13eaa6..516f72a 100644 --- a/tests/test_sheet_revision.py +++ b/tests/test_sheet_revision.py @@ -146,3 +146,40 @@ async def test_clouds_are_refused_before_any_row_when_the_engine_has_no_revcloud assert excinfo.value.capability == "revcloud" assert len(await backend.entity_list()) == before assert (await read_revisions(backend)) == () + + +@pytest.mark.asyncio +async def test_a_revision_on_a_layout_is_still_written_once_per_letter(backend): + """`read_revisions` follows the current space, so reading it before + `enter_layout` inspected Model while the block went on the layout -- and + every rerun stacked a second heading band on top of the first.""" + await backend.layout_create("SHEET1") + first = await add_revision(backend, rev="B", description="bore 40H7", layout="SHEET1") + assert first["created"] is True + again = await add_revision(backend, rev="B", description="bore 40H7", layout="SHEET1") + assert again["created"] is False + assert again["handle"] == first["handle"] + assert (await backend.layout_list())["current"] == "Model", "the caller's tab must come back" + await backend.layout_set_current("SHEET1") + try: + texts = [e.properties["text"] for e in await backend.entity_list(type_filter="TEXT")] + assert texts == ["REV", "DESCRIPTION", "DATE", "BY", "B", "bore 40H7"] + rows = await read_revisions(backend) + assert [r["rev"] for r in rows] == ["B"] + finally: + await backend.layout_set_current("Model") + + +@pytest.mark.asyncio +async def test_revisions_are_read_past_the_entity_list_page(backend): + """200 notes fill `entity_list`'s default page. The idempotency guard used + to see no revisions at all, and `index = len(existing)` restarted at 0.""" + for index in range(200): + await backend.entity_create_text(text=f"NOTE {index}", x=float(index), y=500.0, height=2.5) + first = await add_revision(backend, rev="A", description="first issue") + assert first["row"] == 1 + assert (await add_revision(backend, rev="A", description="first issue"))["created"] is False + second = await add_revision(backend, rev="B", description="bore 40H7") + assert second["row"] == 2, "the second band must sit below the first, not on top of it" + assert second["bbox"]["min"][1] < first["bbox"]["min"][1] + assert [r["rev"] for r in await read_revisions(backend)] == ["A", "B"] From 3ef316d52000fc988fe145cc5641c3b310fffb83 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 23:03:57 +0300 Subject: [PATCH 6/7] feat(refs): xrefs, raster underlays, data extract and real DWG output SECTION 24 completes at eleven tools with xref_attach, xref_manage, image_attach, data_extract and drawing_export_dwg, on the four contract members that genuinely differ per engine -- backends/contracts/refs.py. Everything else in the sheet track composes the generic members and is written once. The ActiveX members were read off the AutoCAD 2026 seat's own type library (C:\Program Files\Common Files\Autodesk Shared\acax25enu.tlb, "AutoCAD 2025 Type Library" 1.0, the library the running 25.x seat registers) before a line of the COM path was written, and the measurement is in the code beside the calls: IAcadModelSpace.AttachExternalReference(PathName, Name, InsertionPoint, Xscale, Yscale, Zscale, Rotation, bOverlay, Password) IAcadModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor, RotationAngle) IAcadBlock: IsXRef (get), Path (get AND put -- so the repath is an assignment, not a method), Reload(), Unload(), Bind(bPrefixName), Detach(), XRefDatabase (get) IAcadDocument.SaveAs(FullFileName, SaveAsType, vSecurityParams) AcSaveAsType: ac2000_dwg=12 ac2004_dwg=24 ac2007_dwg=36 ac2010_dwg=48 ac2013_dwg=60 ac2018_dwg=64 (=acNative) The running seat itself was not answering COM while this landed (its window was Not Responding and GetActiveObject never returned), so the type library -- which is the same seat's declared surface and reads without a call into the process -- is what the members are measured from; no document of the operator's was opened or touched. This repository has been bitten four times by a fake that invented a member, so the COM fakes in the tests model those names and nothing else. ActiveX angles are radians, so the degree-to-radian conversion happens at that boundary and nowhere else. Headlessly, ezdxf.xref.attach carries the attachment (measured on ezdxf 1.4.4: block flags 20 for an attachment and 24 for an overlay, so "is this an xref" is flags & (BLK_XREF | BLK_XREF_OVERLAY) -- masking BLK_XREF alone misses every overlay), add_image_def + add_image the raster, with Pillow reading the pixel size so the placed image keeps the file's aspect. Three capability keys, declared in both maps: dwg_write -- measured absent here (odafc.is_installed() is False), so the ezdxf entry is a runtime re-evaluation like viewport_render's matplotlib check rather than a hardcoded False: installing the ODA File Converter starts the call working with no edit to the map. The refusal names the install route and drawing_export_dxf. xref_live -- reload and bind need a live seat's xref manager and are refused headlessly; list, detach and path are headless and are not behind the key. xlsx_write -- openpyxl is optional and absent here; CSV is always available, so the refusal never blocks the deliverable and says so. Two numbers the plan predicted wrongly are corrected to what the registry actually does. `block` outranks `drawing` in _GROUP_TAG_PRIORITY, so the two xref tools file under `blocks` (9 -> 11) beside block_insert -- which is where an xref belongs, being a block definition that lives in another file -- and `drawing` moves 11 -> 12, not 11 -> 14. And `data_extract` must not say "parts list" in its own text: that is the golden discovery query for entity_create_table, and a file writer outranking the tool that draws the thing is a regression the tuning bar catches. It is described by its destination -- a file -- instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 8 +- backends/base.py | 2 + backends/com_backend.py | 203 +++++++++++++++++ backends/contracts/__init__.py | 2 + backends/contracts/refs.py | 98 +++++++++ backends/ezdxf_backend.py | 250 +++++++++++++++++++++ discovery/aliases.py | 60 +++++ docs/tool-inventory.json | 80 ++++++- engineering/sheet/__init__.py | 4 + engineering/sheet/extract.py | 81 +++++++ server.py | 177 ++++++++++++++- tests/test_refs_contract.py | 390 +++++++++++++++++++++++++++++++++ tests/test_sheet_extract.py | 89 ++++++++ tests/test_tool_registry.py | 21 +- 14 files changed, 1449 insertions(+), 16 deletions(-) create mode 100644 backends/contracts/refs.py create mode 100644 engineering/sheet/extract.py create mode 100644 tests/test_refs_contract.py create mode 100644 tests/test_sheet_extract.py diff --git a/README.md b/README.md index 2e3fd90..2cadf2d 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests. -> 210 is the **registered** count; a default install advertises 205 over `tools/list`, +> **v1.5 release snapshot:** 215 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 215 is the **registered** count; a default install advertises 210 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists **A big MCP server is expensive to be connected to.** The full catalog costs a client **59,315 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**. -**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **187 AutoCAD command names and 990 synonym phrases** covering all 210 tools. A test refuses to let a tool exist without one. +**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **193 AutoCAD command names and 1019 synonym phrases** covering all 215 tools. A test refuses to let a tool exist without one. | Advertised surface | Tools seen | Idle cost | |---|---:|---:| @@ -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 | -210 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. +215 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/backends/base.py b/backends/base.py index 951cb02..99a24e0 100644 --- a/backends/base.py +++ b/backends/base.py @@ -33,6 +33,7 @@ from backends.contracts import ( LinetypeContract, PageSetupContract, PremiumContract, + RefsContract, SettingsContract, SolidContract, StylesContract, @@ -190,6 +191,7 @@ class AutoCADBackend( SettingsContract, StylesContract, EnvironmentContract, + RefsContract, ): """All backends implement this interface. diff --git a/backends/com_backend.py b/backends/com_backend.py index 667bf8f..0218371 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -16,6 +16,7 @@ import time import uuid from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor +from importlib.util import find_spec from pathlib import Path from typing import Any @@ -157,6 +158,28 @@ def _apoint(x: float, y: float, z: float = 0.0): ) +def _image_pixels(path: str) -> tuple[int, int]: + """Pixel size of a raster; the placed size is pixels x scale on both engines. + + Copied from the ezdxf backend rather than imported: the two backends must + not depend on each other. + """ + try: + from PIL import Image + except ImportError as exc: # pragma: no cover - Pillow ships with [pdf] + raise ValueError( + "image_attach needs Pillow to read the image's pixel size; " + 'install it with: pip install -e ".[pdf]"' + ) from exc + try: + with Image.open(path) as image: + return int(image.width), int(image.height) + except Exception as exc: + raise ValueError( + f"image_attach: {path} is not an image this server can read ({exc})" + ) from exc + + def _av(values: list[float]): """Create a VARIANT double-array from a flat list.""" return win32com.client.VARIANT( @@ -1536,6 +1559,16 @@ class ComBackend(AutoCADBackend): "interactive_prompt": FeatureCapability( True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out" ), + # Track G keys. + "dwg_write": FeatureCapability(True, "native", reason="document_saveas"), + "xref_live": FeatureCapability( + True, "native", reason="block_reload_bind_detach_on_the_live_seat" + ), + "xlsx_write": ( + FeatureCapability(True, "openpyxl") + if find_spec("openpyxl") is not None + else FeatureCapability(False, reason="optional_dependency_missing:openpyxl") + ), }, ) @@ -1805,6 +1838,176 @@ class ComBackend(AutoCADBackend): async def drawing_export_dxf(self, path: str) -> dict: return await self.drawing_save_as(path, "dxf") + # ── external references, rasters and DWG (v1.6 track G) ────────────────── + # + # Measured on AutoCAD 2026 (AutoCAD.Application 25.1s), from the live + # seat's type library: + # ModelSpace.AttachExternalReference(PathName, Name, InsertionPoint, + # Xscale, Yscale, Zscale, Rotation, bOverlay, Password) + # ModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor, + # RotationAngle) + # Block: IsXRef, Path, Name, Reload(), Unload(), Bind(bPrefixName), + # Detach(), XRefDatabase + # Document.SaveAs(FullFileName, SaveAsType, vSecurityParams) + # ActiveX angles are radians; the tool boundary is degrees, so every + # rotation crosses here and nowhere else. + + #: AcSaveAsType, read off the same type library. + _DWG_SAVE_AS = { + "R2000": 12, + "R2004": 24, + "R2007": 36, + "R2010": 48, + "R2013": 60, + "R2018": 64, + } + + async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict: + from backends.contracts.refs import XREF_KINDS + + if kind not in XREF_KINDS: + raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}") + source = Path(path) + if not source.is_file(): + raise ValueError(f"xref_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"xref_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"xref_attach: scale must be positive, got {scale!r}") + name = source.stem.upper() + factor = float(scale) + radians = math.radians(float(rotation)) + + def _sync(): + doc = _acad_doc() + reference = doc.ModelSpace.AttachExternalReference( + str(source), + name, + _apoint(x, y), + factor, + factor, + factor, + radians, + kind == "overlay", + ) + return { + "ok": True, + "name": name, + "path": str(source), + "kind": kind, + "handle": reference.Handle, + "at": [x, y], + "scale": factor, + "rotation": float(rotation), + "backend": self.name, + } + + return await self._run(_sync) + + async def xref_manage(self, name, action, new_path=None) -> dict: + from backends.contracts.refs import XREF_ACTIONS + + if action not in XREF_ACTIONS: + raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}") + if action == "path" and not (new_path or "").strip(): + raise ValueError("xref_manage(action='path') needs new_path") + + def _sync(): + doc = _acad_doc() + + def _xref_blocks(): + found = [] + for index in range(doc.Blocks.Count): + block = doc.Blocks.Item(index) + try: + if block.IsXRef: + found.append(block) + except Exception: # noqa: BLE001 - a block that cannot answer is not an xref + continue + return found + + if action == "list": + rows = [ + {"name": b.Name, "path": str(b.Path or ""), "kind": "attach", "inserts": None} + for b in _xref_blocks() + ] + rows.sort(key=lambda row: row["name"]) + return {"ok": True, "xrefs": rows, "backend": self.name} + + key = str(name).strip() + block = next((b for b in _xref_blocks() if b.Name == key), None) + if block is None: + raise ValueError( + f"xref_manage: {key!r} is not an external reference in this drawing" + ) + if action == "reload": + block.Reload() + return {"ok": True, "name": key, "action": action, "backend": self.name} + if action == "bind": + block.Bind(False) + return {"ok": True, "name": key, "action": action, "backend": self.name} + if action == "detach": + block.Detach() + return {"ok": True, "name": key, "inserts_removed": None, "backend": self.name} + block.Path = str(new_path) + return {"ok": True, "name": key, "path": str(new_path), "backend": self.name} + + return await self._run(_sync) + + async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict: + source = Path(path) + if not source.is_file(): + raise ValueError(f"image_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"image_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"image_attach: scale must be positive, got {scale!r}") + px, py = _image_pixels(str(source)) + factor = float(scale) + radians = math.radians(float(rotation)) + + def _sync(): + doc = _acad_doc() + raster = doc.ModelSpace.AddRaster(str(source), _apoint(x, y), factor, radians) + return { + "ok": True, + "path": str(source), + "handle": raster.Handle, + "at": [x, y], + "scale": factor, + "rotation": float(rotation), + "pixels": [px, py], + "size_mm": [px * factor, py * factor], + "backend": self.name, + } + + return await self._run(_sync) + + async def drawing_export_dwg(self, path, version="R2018") -> dict: + from backends.contracts.refs import DWG_VERSIONS + + if version not in DWG_VERSIONS: + raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}") + save_as_type = self._DWG_SAVE_AS[version] + + def _sync(): + doc = _acad_doc() + doc.SaveAs(path, save_as_type) + target = Path(path) + return { + "ok": True, + "path": path, + "version": version, + "bytes": target.stat().st_size if target.exists() else None, + "backend": self.name, + } + + return await self._run(_sync) + async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: """Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file. diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py index 49835f5..6665641 100644 --- a/backends/contracts/__init__.py +++ b/backends/contracts/__init__.py @@ -24,6 +24,7 @@ from backends.contracts.layouts import LayoutContract from backends.contracts.linetypes import LinetypeContract from backends.contracts.pagesetup import PageSetupContract from backends.contracts.premium import PremiumContract +from backends.contracts.refs import RefsContract from backends.contracts.settings import SettingsContract from backends.contracts.solids import SolidContract from backends.contracts.styles import StylesContract @@ -48,6 +49,7 @@ __all__ = [ "LinetypeContract", "PageSetupContract", "PremiumContract", + "RefsContract", "SettingsContract", "SolidContract", "StylesContract", diff --git a/backends/contracts/refs.py b/backends/contracts/refs.py new file mode 100644 index 0000000..f05faec --- /dev/null +++ b/backends/contracts/refs.py @@ -0,0 +1,98 @@ +"""External references, raster images and DWG output (v1.6 tracks B+G, group G). + +These are the only four operations of the sheet track that genuinely differ per +engine, so they are the only four that become contract members. Everything else +in `engineering/sheet/` composes the generic members and is written once. + +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +#: An xref is attached (its own xrefs come with it) or overlaid (they do not). +XREF_KINDS = ("attach", "overlay") + +#: `list`, `detach` and `path` are headless; `reload` and `bind` need a live +#: seat and are refused with the `xref_live` capability on the ezdxf engine. +XREF_ACTIONS = ("list", "reload", "bind", "detach", "path") + +#: DWG formats both engines name the same way. COM maps them to AcSaveAsType +#: (measured on AutoCAD 2026: 12/24/36/48/60/64); ezdxf maps them through +#: `ezdxf.addons.odafc.VERSION_MAP`, which covers exactly these six and more. +DWG_VERSIONS = ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018") + + +class RefsContract(ABC): + @abstractmethod + async def xref_attach( + self, + path: str, + at, + scale: float, + rotation: float, + kind: str, + ) -> dict: + """Attach an external reference and insert it once. + + The block name is the referenced file's stem, which is what both + engines do. Returns ``{ok, name, path, kind, handle, at, scale, + rotation, backend}``. + + Refuses, before anything is written: a `kind` outside + :data:`XREF_KINDS`, a file that does not exist, a non-finite + coordinate/scale/rotation, a non-positive scale, and a block name the + drawing already holds (re-attaching would either silently replace the + definition or raise mid-write). + """ + ... + + @abstractmethod + async def xref_manage(self, name: str, action: str, new_path: str | None) -> dict: + """One of :data:`XREF_ACTIONS` on one external reference. + + ``list`` ignores `name` and returns + ``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``path`` + rewrites the saved path to `new_path` and returns the new one. + ``detach`` removes every insert of the xref and its definition, and + reports ``inserts_removed``. ``reload`` and ``bind`` need a live seat's + xref manager: the headless engine raises + ``UnsupportedCapabilityError("xref_live", ...)`` rather than pretending, + which is why the key exists; ``list``/``detach``/``path`` are *not* + behind it. + + Refuses an action outside the vocabulary, an unknown xref name, and a + ``path`` action with no `new_path`. + """ + ... + + @abstractmethod + async def image_attach(self, path: str, at, scale: float, rotation: float) -> dict: + """Attach a raster image as an underlay. + + `scale` is drawing units per pixel, so the placed size is the image's + pixel size times `scale` and the aspect ratio is the file's. Returns + ``{ok, path, handle, at, scale, rotation, pixels: [w, h], + size_mm: [w, h], backend}``. + + Refuses a file that does not exist, a file no image reader can open + (naming it), a non-positive scale and a non-finite number -- all before + anything is written. + """ + ... + + @abstractmethod + async def drawing_export_dwg(self, path: str, version: str) -> dict: + """Write the current drawing as a real DWG. + + Returns ``{ok, path, version, bytes, backend}``. Refuses a `version` + outside :data:`DWG_VERSIONS` by name. Headlessly this needs the ODA + File Converter and otherwise raises + ``UnsupportedCapabilityError("dwg_write", ...)`` naming the install + route; on a live seat it is ``Document.SaveAs`` with the matching + ``AcSaveAsType``, which -- as AutoCAD's own SaveAs does -- leaves the + session bound to the file it just wrote. + """ + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index e020fa8..582dac3 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -202,6 +202,39 @@ def _refuse_dwg(operation: str, path: str) -> UnsupportedCapabilityError: ) +def _odafc_available() -> bool: + """Is the ODA File Converter on this machine right now? + + Re-evaluated per call, the way the matplotlib check behind + ``viewport_render`` is: installing the converter must change the answer + without editing the map. + """ + try: + from ezdxf.addons import odafc + + return bool(odafc.is_installed()) + except Exception: # noqa: BLE001 - an import failure is simply "not installed" + return False + + +def _image_pixels(path: str) -> tuple[int, int]: + """Pixel size of a raster, so the placed image keeps the file's aspect.""" + try: + from PIL import Image + except ImportError as exc: # pragma: no cover - Pillow ships with [pdf] + raise ValueError( + "image_attach needs Pillow to read the image's pixel size; " + 'install it with: pip install -e ".[pdf]"' + ) from exc + try: + with Image.open(path) as image: + return int(image.width), int(image.height) + except Exception as exc: + raise ValueError( + f"image_attach: {path} is not an image this server can read ({exc})" + ) from exc + + def _audit_entry(entry) -> dict: """Flatten an ezdxf ``ErrorEntry`` into something a client can read. @@ -1676,6 +1709,30 @@ class EzdxfBackend(AutoCADBackend): "interactive_prompt": FeatureCapability( False, reason="no_operator_to_prompt_headlessly" ), + # Track G keys. `dwg_write` is a runtime re-evaluation, not a + # hardcoded False: installing the converter must change the + # answer without editing this map, exactly as `viewport_render` + # follows matplotlib. + "dwg_write": ( + FeatureCapability(True, "odafc", reason="oda_file_converter_installed") + if _odafc_available() + else FeatureCapability( + False, + reason=( + "requires_oda_file_converter:" + "https://www.opendesign.com/guestfiles/oda_file_converter" + ), + ) + ), + "xref_live": FeatureCapability( + False, + reason="reload_and_bind_need_a_live_seat;list_detach_path_are_headless", + ), + "xlsx_write": ( + FeatureCapability(True, "openpyxl") + if find_spec("openpyxl") is not None + else FeatureCapability(False, reason="optional_dependency_missing:openpyxl") + ), }, ) @@ -2322,6 +2379,199 @@ class EzdxfBackend(AutoCADBackend): async def drawing_export_dxf(self, path: str) -> dict: return await self.drawing_save_as(path, "dxf") + # ── external references, rasters and DWG (v1.6 track G) ────────────────── + + async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict: + from backends.contracts.refs import XREF_KINDS + + if kind not in XREF_KINDS: + raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}") + source = Path(path) + if not source.is_file(): + raise ValueError(f"xref_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"xref_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"xref_attach: scale must be positive, got {scale!r}") + name = source.stem.upper() + + def _sync(): + from ezdxf import xref + + doc = self._require_doc() + if name in doc.blocks: + raise ValueError( + f"xref_attach: the drawing already holds a block named {name!r}; " + "detach it first with xref_manage(action='detach')" + ) + insert = xref.attach( + doc, + block_name=name, + filename=str(source), + insert=(x, y), + scale=float(scale), + rotation=float(rotation), + overlay=(kind == "overlay"), + ) + self._mark_dirty() + return { + "ok": True, + "name": name, + "path": str(source), + "kind": kind, + "handle": insert.dxf.handle, + "at": [x, y], + "scale": float(scale), + "rotation": float(rotation), + "backend": self.name, + } + + return await self._async(_sync) + + async def xref_manage(self, name, action, new_path=None) -> dict: + from backends.contracts.refs import XREF_ACTIONS + + if action not in XREF_ACTIONS: + raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}") + if action in ("reload", "bind"): + raise UnsupportedCapabilityError( + "xref_live", + f"xref_manage(action={action!r}): the {self.name} backend has no live xref " + "manager. list, detach and path work headlessly; reload and bind need the " + "com backend (a running AutoCAD seat).", + ) + if action == "path" and not (new_path or "").strip(): + raise ValueError("xref_manage(action='path') needs new_path") + + def _sync(): + from ezdxf.lldxf import const + + doc = self._require_doc() + # Measured on ezdxf 1.4.4: `xref.attach` leaves an attachment's + # block flags at 20 and an overlay's at 24, so the question "is + # this an xref" is the *pair* of bits. Masking BLK_XREF alone + # misses every overlay. + xref_mask = const.BLK_XREF | const.BLK_XREF_OVERLAY + + def _rows(): + rows = [] + for block in doc.blocks: + flags = int(block.block.dxf.flags) + if not flags & xref_mask: + continue + rows.append( + { + "name": block.name, + "path": str(block.block.dxf.xref_path or ""), + "kind": "overlay" if flags & const.BLK_XREF_OVERLAY else "attach", + "inserts": sum( + 1 + for entity in doc.modelspace() + if entity.dxftype() == "INSERT" and entity.dxf.name == block.name + ), + } + ) + rows.sort(key=lambda row: row["name"]) + return rows + + if action == "list": + return {"ok": True, "xrefs": _rows(), "backend": self.name} + + key = str(name).strip() + row = next((r for r in _rows() if r["name"] == key), None) + if row is None: + raise ValueError( + f"xref_manage: {key!r} is not an external reference in this drawing " + f"(have: {', '.join(r['name'] for r in _rows()) or 'none'})" + ) + if action == "path": + doc.blocks.get(key).block.dxf.xref_path = str(new_path) + self._mark_dirty() + return {"ok": True, "name": key, "path": str(new_path), "backend": self.name} + + victims = [ + entity + for entity in doc.modelspace() + if entity.dxftype() == "INSERT" and entity.dxf.name == key + ] + for entity in victims: + doc.modelspace().delete_entity(entity) + doc.blocks.delete_block(key, safe=False) + self._mark_dirty() + return { + "ok": True, + "name": key, + "inserts_removed": len(victims), + "backend": self.name, + } + + return await self._async(_sync) + + async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict: + source = Path(path) + if not source.is_file(): + raise ValueError(f"image_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"image_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"image_attach: scale must be positive, got {scale!r}") + px, py = _image_pixels(str(source)) + width, height = px * float(scale), py * float(scale) + + def _sync(): + doc = self._require_doc() + image_def = doc.add_image_def(str(source), size_in_pixel=(px, py)) + image = self._msp().add_image(image_def, (x, y), (width, height), float(rotation)) + self._mark_dirty() + return { + "ok": True, + "path": str(source), + "handle": image.dxf.handle, + "at": [x, y], + "scale": float(scale), + "rotation": float(rotation), + "pixels": [px, py], + "size_mm": [width, height], + "backend": self.name, + } + + return await self._async(_sync) + + async def drawing_export_dwg(self, path, version="R2018") -> dict: + from backends.contracts.refs import DWG_VERSIONS + + if version not in DWG_VERSIONS: + raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}") + if not _odafc_available(): + raise UnsupportedCapabilityError( + "dwg_write", + f"drawing_export_dwg: the {self.name} backend writes DWG only through the " + "ODA File Converter, which is not installed here. Install it from " + "https://www.opendesign.com/guestfiles/oda_file_converter and this call " + "starts working, or export from the com backend (a running AutoCAD seat). " + "drawing_export_dxf writes a DXF that AutoCAD opens unchanged.", + ) + + def _sync(): + from ezdxf.addons import odafc + + doc = self._require_doc() + self._refuse_path_held_elsewhere("drawing_export_dwg", path) + odafc.export_dwg(doc, path, version=version, replace=True) + return { + "ok": True, + "path": path, + "version": version, + "bytes": Path(path).stat().st_size, + "backend": self.name, + } + + return await self._async(_sync) + async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: """Export to PDF via ezdxf's matplotlib backend. diff --git a/discovery/aliases.py b/discovery/aliases.py index f3c6d72..8936242 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1027,6 +1027,66 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "pozisyon numarasi", ), ), + "xref_attach": ToolAliases( + acad=("XATTACH",), + synonyms=( + "attach an xref", + "external reference", + "xref", + "reference another drawing", + "overlay a drawing", + "harici referans", + ), + ), + "xref_manage": ToolAliases( + acad=("XREF", "XBIND"), + synonyms=( + "list the xrefs", + "reload an xref", + "bind an xref", + "detach an xref", + "repath an xref", + "xref manager", + "broken xref path", + ), + ), + "image_attach": ToolAliases( + acad=("IMAGEATTACH",), + synonyms=( + "attach an image", + "raster underlay", + "insert a png", + "background image", + "scanned drawing", + "resim ekle", + ), + ), + "data_extract": ToolAliases( + acad=("DATAEXTRACTION", "EATTEXT"), + synonyms=( + # Not the bare phrase "parts list": that is the golden query for + # `entity_create_table`, and a file writer must not outrank the + # tool that draws the thing. This one is asked for by its + # destination -- a file -- which is what distinguishes it. + "export the bill of materials", + "extract attributes", + "bom to csv", + "bom to excel", + "data extraction", + "excele aktar", + ), + ), + "drawing_export_dwg": ToolAliases( + acad=("SAVEAS",), + synonyms=( + "export dwg", + "save as dwg", + "write a dwg", + "autocad 2018 format", + "downgrade to r2000", + "dwg olarak kaydet", + ), + ), # ── Layers & linetypes ────────────────────────────────────────────────── "layer_create": ToolAliases( acad=("LAYER", "LA"), diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index e25b609..75f7e0e 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": 210, + "tools": 215, "resources": 8, "prompts": 5, "groups": 23 @@ -11,12 +11,12 @@ "groups": { "analysis": 12, "batch": 3, - "blocks": 9, + "blocks": 11, "corner_ops": 4, "dimensions": 5, - "drawing": 11, - "engineering": 16, - "entity_creation": 18, + "drawing": 12, + "engineering": 17, + "entity_creation": 19, "entity_modification": 16, "entity_query": 9, "environment": 22, @@ -414,6 +414,21 @@ "destructiveHint": false } }, + { + "name": "data_extract", + "group": "engineering", + "cost": "safe", + "summary": "Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.", + "tags": [ + "engineering", + "query", + "sheet" + ], + "annotations": { + "title": "Sheet: Extract Data", + "destructiveHint": false + } + }, { "name": "datum_feature", "group": "engineering", @@ -697,6 +712,19 @@ "destructiveHint": true } }, + { + "name": "drawing_export_dwg", + "group": "drawing", + "cost": "safe", + "summary": "Write the drawing as a real DWG at a chosen AutoCAD version.", + "tags": [ + "drawing" + ], + "annotations": { + "title": "Drawing: Export DWG", + "destructiveHint": false + } + }, { "name": "drawing_export_dxf", "group": "drawing", @@ -1649,6 +1677,20 @@ "destructiveHint": false } }, + { + "name": "image_attach", + "group": "entity_creation", + "cost": "mutate", + "summary": "Attach a raster image (PNG/JPG) as an underlay.", + "tags": [ + "create", + "drawing" + ], + "annotations": { + "title": "Sheet: Attach Image", + "destructiveHint": false + } + }, { "name": "keyway_draw_keyed_bore", "group": "engineering", @@ -2988,6 +3030,34 @@ "title": "Set Viewport Scale", "destructiveHint": false } + }, + { + "name": "xref_attach", + "group": "blocks", + "cost": "mutate", + "summary": "Attach a DWG/DXF as an external reference (attachment or overlay).", + "tags": [ + "block", + "drawing" + ], + "annotations": { + "title": "Sheet: Attach Xref", + "destructiveHint": false + } + }, + { + "name": "xref_manage", + "group": "blocks", + "cost": "mutate", + "summary": "List, reload, bind, detach or repath an external reference.", + "tags": [ + "block", + "drawing" + ], + "annotations": { + "title": "Sheet: Manage Xrefs", + "destructiveHint": true + } } ], "resources": [ diff --git a/engineering/sheet/__init__.py b/engineering/sheet/__init__.py index 76f1438..e6a99c3 100644 --- a/engineering/sheet/__init__.py +++ b/engineering/sheet/__init__.py @@ -15,6 +15,7 @@ from engineering.sheet.bom import ( rows_from_records, table_prims, ) +from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available from engineering.sheet.frames import ( CENTRING_OVERSHOOT, EDGE_MARGIN, @@ -50,6 +51,7 @@ __all__ = [ "COLUMN_LABELS", "DEFAULT_COLUMNS", "EDGE_MARGIN", + "EXTRACT_FORMATS", "FILING_MARGIN", "LIST_WIDTH", "REVISION_COLUMNS", @@ -80,5 +82,7 @@ __all__ = [ "rows_from_records", "sheet_size", "table_prims", + "write_rows", + "xlsx_available", "zone_divisions", ] diff --git a/engineering/sheet/extract.py b/engineering/sheet/extract.py new file mode 100644 index 0000000..be25a81 --- /dev/null +++ b/engineering/sheet/extract.py @@ -0,0 +1,81 @@ +"""Parts-list rows to a file: CSV always, XLSX when openpyxl is installed. + +CSV needs nothing but the standard library, so the deliverable is never blocked +-- which is exactly why `xlsx_write` can be a capability rather than a hard +dependency. The refusal names both the missing package and the format that +always works. +""" + +from __future__ import annotations + +import csv +from importlib.util import find_spec +from pathlib import Path + +from backends.capability import UnsupportedCapabilityError +from engineering.sheet.bom import BOM_COLUMNS, COLUMN_LABELS, DEFAULT_COLUMNS + +__all__ = ["EXTRACT_FORMATS", "write_rows", "xlsx_available"] + +EXTRACT_FORMATS = ("csv", "xlsx") + + +def xlsx_available() -> bool: + """Is openpyxl importable right now? Re-evaluated per call, so installing + the package changes the answer without a restart.""" + return find_spec("openpyxl") is not None + + +def _prepare(rows, columns): + chosen = tuple(DEFAULT_COLUMNS if columns is None else columns) + unknown = [c for c in chosen if c not in BOM_COLUMNS] + if unknown: + raise ValueError( + f"unknown parts-list column(s) {', '.join(unknown)}; " + f"available: {', '.join(BOM_COLUMNS)}" + ) + body = [[str(row.get(column, "")) for column in chosen] for row in rows] + if not body: + raise ValueError("data_extract: there are no parts-list rows to write") + return chosen, [COLUMN_LABELS[c] for c in chosen], body + + +def write_rows(rows, path: str, *, columns=None, fmt: str = "csv") -> dict: + """Write the rows to `path`. Refuses before the file is created.""" + if fmt not in EXTRACT_FORMATS: + raise ValueError(f"format must be one of {', '.join(EXTRACT_FORMATS)}; got {fmt!r}") + chosen, heading, body = _prepare(rows, columns) + if fmt == "xlsx" and not xlsx_available(): + raise UnsupportedCapabilityError( + "xlsx_write", + "data_extract(format='xlsx') needs openpyxl, which is not installed. " + 'Install it with: pip install openpyxl (or pip install -e ".[full]"), ' + "or ask for format='csv', which always works and carries the same rows.", + ) + + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + if fmt == "csv": + with open(target, "w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(heading) + writer.writerows(body) + else: + from openpyxl import Workbook + + book = Workbook() + sheet = book.active + sheet.title = "Parts list" + sheet.append(heading) + for row in body: + sheet.append(row) + book.save(target) + + return { + "ok": True, + "path": str(target), + "format": fmt, + "columns": list(chosen), + "rows": len(body), + "bytes": target.stat().st_size, + } diff --git a/server.py b/server.py index 4f52b46..b1a4849 100644 --- a/server.py +++ b/server.py @@ -8124,7 +8124,7 @@ async def drawing_properties_set( # --------------------------------------------------------------------------- -# ── SECTION 24: Sheet & Delivery (6 tools) ────────────────────────────────── +# ── SECTION 24: Sheet & Delivery (11 tools) ───────────────────────────────── # --------------------------------------------------------------------------- @@ -8574,6 +8574,181 @@ async def balloon_add( ) +@cad_tool( + summary="Attach a DWG/DXF as an external reference (attachment or overlay).", cost="mutate" +) +@mcp.tool( + annotations={"title": "Sheet: Attach Xref", "destructiveHint": False}, + tags={"drawing", "block"}, +) +async def xref_attach( + path: Annotated[str, Field(description="Full path of the drawing to reference.")], + x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0, + y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0, + scale: Annotated[float, Field(default=1.0, gt=0, description="Uniform scale.")] = 1.0, + rotation: Annotated[ + float, Field(default=0.0, description="Rotation in degrees, CCW from +X.") + ] = 0.0, + kind: Annotated[ + str, + Field( + default="attach", + description="'attach' (the reference's own xrefs come with it) or 'overlay' " + "(they do not, so a circular reference cannot form).", + ), + ] = "attach", + ctx: Context = None, +) -> dict: + """Attach an external reference and insert it once. Both engines. + + The block takes the referenced file's stem as its name. Refusals, all + before anything is written: a `kind` that is not 'attach' or 'overlay', a + file that does not exist, a non-positive or non-finite scale, and a block + name the drawing already holds — detach it first with + `xref_manage(action='detach')`. + """ + validated = validate_path(path) + await ctx.info(f"Xref {kind}: {validated}") + return await _backend(ctx).xref_attach(str(validated), (x, y), scale, rotation, kind) + + +@cad_tool(summary="List, reload, bind, detach or repath an external reference.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Manage Xrefs", "destructiveHint": True}, + tags={"drawing", "block"}, +) +async def xref_manage( + action: Annotated[ + str, + Field(description="list | reload | bind | detach | path."), + ], + name: Annotated[str, Field(default="", description="Xref name; ignored by 'list'.")] = "", + new_path: Annotated[ + str, Field(default="", description="Required by 'path': the reference's new location.") + ] = "", + ctx: Context = None, +) -> dict: + """One operation on one external reference. + + `list`, `detach` and `path` work on both engines. `reload` and `bind` need + a live AutoCAD seat's xref manager: headlessly they are refused with + `capability: "xref_live"` rather than pretended, and the refusal says which + engine does them. Other refusals: an action outside the five, an unknown + xref name, a 'path' action with no `new_path`. On the live engine a listed + row's `inserts` is null — ActiveX reports no per-xref insert count and this + server does not invent one. + """ + await ctx.info(f"Xref {action} {name or '(all)'}") + validated = str(validate_path(new_path)) if new_path else None + return await _backend(ctx).xref_manage(name, action, validated) + + +@cad_tool(summary="Attach a raster image (PNG/JPG) as an underlay.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Attach Image", "destructiveHint": False}, + tags={"drawing", "create"}, +) +async def image_attach( + path: Annotated[str, Field(description="Full path of the raster image.")], + x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0, + y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0, + scale: Annotated[ + float, + Field( + default=1.0, + gt=0, + description="Drawing units per pixel: an 800x400 image at 0.1 is 80x40 mm.", + ), + ] = 1.0, + rotation: Annotated[float, Field(default=0.0, description="Rotation in degrees.")] = 0.0, + ctx: Context = None, +) -> dict: + """Attach a raster underlay at the file's own aspect ratio. Both engines. + + `scale` is drawing units per pixel, so the placed size is the image's pixel + size times `scale` and the payload reports both. Refusals, all before + anything is written: a file that does not exist, a file no image reader can + open (named), a non-positive or non-finite scale. + """ + validated = validate_path(path) + await ctx.info(f"Image underlay: {validated}") + return await _backend(ctx).image_attach(str(validated), (x, y), scale, rotation) + + +@cad_tool( + summary="Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.", + cost="safe", +) +@mcp.tool( + annotations={"title": "Sheet: Extract Data", "destructiveHint": False}, + tags={"engineering", "sheet", "query"}, +) +async def data_extract( + path: Annotated[str, Field(description="File to write, ending .csv or .xlsx.")], + fmt: Annotated[ + str, + Field(default="csv", description="'csv' (always available) or 'xlsx' (needs openpyxl)."), + ] = "csv", + layer: Annotated[ + str, Field(default="", description="Only read INSERTs on this layer; empty reads all.") + ] = "", + group_by: Annotated[ + str, + Field(default="designation", description="Group identical parts by this field, or ''."), + ] = "designation", + columns: Annotated[list[str] | None, Field(default=None)] = None, + ctx: Context = None, +) -> dict: + """The same rows `bom_extract` returns, written to a file. + + Refusals: a format that is not 'csv' or 'xlsx'; a drawing with nothing to + itemise (nothing is written); and `fmt='xlsx'` without openpyxl, which + carries `capability: "xlsx_write"` and names both the package to install + and the CSV route that always works. + """ + from engineering.sheet.bom import extract_records, rows_from_records + from engineering.sheet.extract import write_rows + + backend = _backend(ctx) + validated = validate_path(path, allow_write=True) + records = await extract_records(backend, layer=layer or None) + rows = rows_from_records(records, columns=columns, group_by=group_by or None) + await ctx.info(f"Data extract: {len(rows)} rows -> {validated}") + return write_rows(rows, str(validated), columns=columns, fmt=fmt) + + +@cad_tool(summary="Write the drawing as a real DWG at a chosen AutoCAD version.", cost="safe") +@mcp.tool( + annotations={"title": "Drawing: Export DWG", "destructiveHint": False}, + tags={"drawing"}, +) +async def drawing_export_dwg( + path: Annotated[str, Field(description="Full path of the .dwg to write.")], + version: Annotated[ + str, + Field( + default="R2018", + description="R2000 | R2004 | R2007 | R2010 | R2013 | R2018.", + ), + ] = "R2018", + ctx: Context = None, +) -> dict: + """Write a real DWG — not a DXF under a .dwg name. + + On a live AutoCAD seat this is `Document.SaveAs` with the matching + AcSaveAsType, which (as AutoCAD's own SaveAs does) leaves the session bound + to the file it just wrote. Headlessly it needs the ODA File Converter; when + that is absent the call is refused with `capability: "dwg_write"` and the + refusal names the install route and `drawing_export_dxf`, which writes a + DXF AutoCAD opens unchanged. Check `system_capabilities` first: the ezdxf + flag is re-evaluated at call time, so installing the converter changes it + without a restart. An unknown version is refused by name with the six. + """ + validated = validate_path(path, allow_write=True) + await ctx.info(f"DWG export {version}: {validated}") + return await _backend(ctx).drawing_export_dwg(str(validated), version) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_refs_contract.py b/tests/test_refs_contract.py new file mode 100644 index 0000000..33512d2 --- /dev/null +++ b/tests/test_refs_contract.py @@ -0,0 +1,390 @@ +"""External references, raster images and DWG output on both engines. + +The three capability keys are the point of this file: a refusal the feature map +never mentions is a lie by omission, so every key raised here is asserted to be +declared on both backends. +""" + +from __future__ import annotations + +from importlib.util import find_spec +from pathlib import Path + +import pytest + +from backends.base import AutoCADBackend, UnsupportedCapabilityError +from backends.com_backend import ComBackend +from backends.contracts.refs import ( + DWG_VERSIONS, + XREF_ACTIONS, + XREF_KINDS, + RefsContract, +) +from backends.ezdxf_backend import EzdxfBackend + +TRACK_G_KEYS = ("dwg_write", "xref_live", "xlsx_write") + + +def _features(backend) -> dict: + return backend.capabilities().to_dict()["features"] + + +def test_the_contract_is_composed_into_both_backends(): + assert issubclass(AutoCADBackend, RefsContract) + assert issubclass(EzdxfBackend, RefsContract) + assert issubclass(ComBackend, RefsContract) + for name in ("xref_attach", "xref_manage", "image_attach", "drawing_export_dwg"): + assert callable(getattr(AutoCADBackend, name, None)), name + + +def test_the_three_keys_are_declared_in_both_capability_maps(): + ezdxf_features = _features(EzdxfBackend()) + com_features = _features(ComBackend()) + for key in TRACK_G_KEYS: + assert key in ezdxf_features, f"{key} undeclared on ezdxf" + assert key in com_features, f"{key} undeclared on com" + + +def test_com_declares_dwg_and_live_xrefs_and_ezdxf_does_not(): + com_features = _features(ComBackend()) + ezdxf_features = _features(EzdxfBackend()) + assert com_features["dwg_write"]["supported"] is True + assert com_features["xref_live"]["supported"] is True + assert ezdxf_features["xref_live"]["supported"] is False + assert "live" in (ezdxf_features["xref_live"]["reason"] or "") + + +def test_ezdxf_dwg_write_is_re_evaluated_from_the_converter_not_hardcoded(): + """The same shape as viewport_render's matplotlib check: the flag follows + the machine, and the reason names the way to change it.""" + from ezdxf.addons import odafc + + feature = _features(EzdxfBackend())["dwg_write"] + assert feature["supported"] is bool(odafc.is_installed()) + if not feature["supported"]: + assert "oda" in (feature["reason"] or "").lower() + + +def test_xlsx_write_follows_openpyxl_on_both_engines(): + present = find_spec("openpyxl") is not None + for backend in (EzdxfBackend(), ComBackend()): + feature = _features(backend)["xlsx_write"] + assert feature["supported"] is present, backend.name + if not present: + assert "openpyxl" in (feature["reason"] or "") + + +def test_the_vocabularies_are_closed(): + assert XREF_KINDS == ("attach", "overlay") + assert XREF_ACTIONS == ("list", "reload", "bind", "detach", "path") + assert DWG_VERSIONS == ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018") + + +# -- ezdxf ------------------------------------------------------------------- + + +@pytest.fixture +def external(tmp_path: Path) -> str: + """A real DXF on disk for the xref to point at.""" + import ezdxf + + doc = ezdxf.new("R2018") + doc.modelspace().add_circle((0, 0), 5) + target = tmp_path / "BASE.dxf" + doc.saveas(target) + return str(target) + + +@pytest.mark.asyncio +async def test_attach_creates_an_xref_block_and_an_insert(backend, external): + result = await backend.xref_attach(external, (10.0, 20.0), 2.0, 30.0, "attach") + assert result["ok"] is True + assert result["name"] == "BASE" + assert result["kind"] == "attach" + insert = await backend.entity_get(result["handle"]) + assert insert.type == "INSERT" + assert insert.properties["block_name"] == "BASE" + + listing = await backend.xref_manage("", "list", None) + assert [row["name"] for row in listing["xrefs"]] == ["BASE"] + assert listing["xrefs"][0]["kind"] == "attach" + assert Path(listing["xrefs"][0]["path"]).name == "BASE.dxf" + assert listing["xrefs"][0]["inserts"] == 1 + + +@pytest.mark.asyncio +async def test_overlay_is_flagged_as_an_overlay(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "overlay") + listing = await backend.xref_manage("", "list", None) + assert listing["xrefs"][0]["kind"] == "overlay" + + +@pytest.mark.asyncio +async def test_attaching_the_same_name_twice_is_refused_before_writing(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + assert "BASE" in str(excinfo.value) + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_a_missing_file_and_a_bad_kind_are_refused(backend, tmp_path): + with pytest.raises(ValueError) as missing: + await backend.xref_attach(str(tmp_path / "nope.dxf"), (0.0, 0.0), 1.0, 0.0, "attach") + assert "nope.dxf" in str(missing.value) + with pytest.raises(ValueError) as kind: + await backend.xref_attach(str(tmp_path), (0.0, 0.0), 1.0, 0.0, "underlay") + assert "underlay" in str(kind.value) + + +@pytest.mark.asyncio +async def test_path_rewrites_the_saved_path_and_detach_removes_the_block( + backend, external, tmp_path +): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + moved = str(tmp_path / "moved" / "BASE.dxf") + changed = await backend.xref_manage("BASE", "path", moved) + assert changed["ok"] is True + assert changed["path"] == moved + assert (await backend.xref_manage("", "list", None))["xrefs"][0]["path"] == moved + + detached = await backend.xref_manage("BASE", "detach", None) + assert detached["ok"] is True and detached["inserts_removed"] == 1 + assert (await backend.xref_manage("", "list", None))["xrefs"] == [] + + +@pytest.mark.asyncio +async def test_reload_and_bind_refuse_headlessly_with_the_xref_live_key(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + for action in ("reload", "bind"): + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.xref_manage("BASE", action, None) + assert excinfo.value.capability == "xref_live" + assert "com" in str(excinfo.value).lower() + + +@pytest.mark.asyncio +async def test_an_unknown_action_and_an_unknown_name_are_refused(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + with pytest.raises(ValueError) as action: + await backend.xref_manage("BASE", "unload", None) + assert "unload" in str(action.value) + with pytest.raises(ValueError) as name: + await backend.xref_manage("NOPE", "detach", None) + assert "NOPE" in str(name.value) + + +@pytest.mark.asyncio +async def test_image_attach_places_the_raster_at_its_pixel_aspect(backend, tmp_path): + from PIL import Image + + png = tmp_path / "logo.png" + Image.new("RGB", (800, 400), "white").save(png) + result = await backend.image_attach(str(png), (5.0, 5.0), 0.1, 0.0) + assert result["ok"] is True + assert result["pixels"] == [800, 400] + assert result["size_mm"] == [80.0, 40.0] + image = await backend.entity_get(result["handle"]) + assert image.type == "IMAGE" + + +@pytest.mark.asyncio +async def test_a_non_image_file_is_refused_before_writing(backend, external): + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await backend.image_attach(external, (0.0, 0.0), 1.0, 0.0) + assert "BASE.dxf" in str(excinfo.value) + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_headless_dwg_export_refuses_with_dwg_write_when_the_converter_is_absent( + backend, tmp_path +): + from ezdxf.addons import odafc + + if odafc.is_installed(): + pytest.skip("the ODA File Converter is installed on this machine") + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R2018") + assert excinfo.value.capability == "dwg_write" + assert "oda" in str(excinfo.value).lower() + assert not (tmp_path / "out.dwg").exists() + + +@pytest.mark.asyncio +async def test_an_unknown_dwg_version_is_refused_on_both_engines(backend, tmp_path): + with pytest.raises(ValueError) as excinfo: + await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R14") + message = str(excinfo.value) + assert "R14" in message + assert "R2018" in message + + +# -- COM, against fakes that model the measured ActiveX members -------------- + + +def _com_backend(): + b = ComBackend() + + async def _run(func, *args, **kwargs): # bypass the single-thread executor + return func(*args, **kwargs) + + b._run = _run + return b + + +def _point(variant): + """The doubles a VARIANT point carries. + + ``_apoint`` hands ActiveX a ``win32com.client.VARIANT``, and that object is + *not* iterable -- measured here: + ``tuple(VARIANT(VT_ARRAY | VT_R8, [1.0, 2.0, 0.0]))`` raises + ``TypeError: 'VARIANT' object is not iterable``. The real ModelSpace reads + the array behind it, so the fakes read ``.value`` rather than pretending + the argument is a plain sequence. + """ + return tuple(getattr(variant, "value", variant)) + + +class _Block: + """Measured members of an AcadBlock that is an xref (AutoCAD 2026).""" + + def __init__(self, name, path="", is_xref=True): + self.Name = name + self.Path = path + self.IsXRef = is_xref + self.calls = [] + + def Reload(self): + self.calls.append("Reload") + + def Bind(self, bPrefixName): + self.calls.append(("Bind", bPrefixName)) + + def Detach(self): + self.calls.append("Detach") + + +class _Blocks: + def __init__(self, blocks): + self._blocks = list(blocks) + + def Item(self, index): + if isinstance(index, int): + return self._blocks[index] + for block in self._blocks: + if block.Name == index: + return block + raise KeyError(index) + + @property + def Count(self): + return len(self._blocks) + + def __iter__(self): + return iter(self._blocks) + + +class _Ref: + def __init__(self, handle="7F"): + self.Handle = handle + + +class _ModelSpace: + def __init__(self): + self.attached = [] + self.rasters = [] + + def AttachExternalReference( + self, PathName, Name, InsertionPoint, Xscale, Yscale, Zscale, Rotation, bOverlay + ): + self.attached.append( + (PathName, Name, _point(InsertionPoint), Xscale, Yscale, Zscale, Rotation, bOverlay) + ) + return _Ref("A1") + + def AddRaster(self, imageFileName, InsertionPoint, ScaleFactor, RotationAngle): + self.rasters.append((imageFileName, _point(InsertionPoint), ScaleFactor, RotationAngle)) + return _Ref("B2") + + +class _ComDoc: + def __init__(self, blocks=()): + self.ModelSpace = _ModelSpace() + self.Blocks = _Blocks(blocks) + self.saved = [] + + def SaveAs(self, FullFileName, SaveAsType): + self.saved.append((FullFileName, SaveAsType)) + + +@pytest.mark.asyncio +async def test_com_attach_calls_the_measured_member_with_radians(monkeypatch, external): + import math + + import backends.com_backend as cb + + doc = _ComDoc() + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + result = await _com_backend().xref_attach(external, (10.0, 20.0), 2.0, 90.0, "overlay") + assert result["handle"] == "A1" + path, name, insert, sx, sy, sz, rotation, overlay = doc.ModelSpace.attached[0] + assert name == "BASE" and overlay is True + assert (sx, sy, sz) == (2.0, 2.0, 2.0) + assert rotation == pytest.approx(math.pi / 2) + assert insert[:2] == (10.0, 20.0) + + +@pytest.mark.asyncio +async def test_com_manage_uses_reload_bind_detach_on_the_block(monkeypatch): + import backends.com_backend as cb + + block = _Block("BASE", path="C:/refs/BASE.dwg") + doc = _ComDoc([block]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + b = _com_backend() + + assert (await b.xref_manage("BASE", "reload", None))["ok"] is True + assert (await b.xref_manage("BASE", "bind", None))["ok"] is True + assert (await b.xref_manage("BASE", "detach", None))["ok"] is True + assert block.calls == ["Reload", ("Bind", False), "Detach"] + + listing = await b.xref_manage("", "list", None) + assert listing["xrefs"][0]["name"] == "BASE" + assert listing["xrefs"][0]["path"] == "C:/refs/BASE.dwg" + + +@pytest.mark.asyncio +async def test_com_raster_calls_addraster_with_radians(monkeypatch, tmp_path): + import math + + from PIL import Image + + import backends.com_backend as cb + + png = tmp_path / "logo.png" + Image.new("RGB", (400, 200), "white").save(png) + doc = _ComDoc() + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + result = await _com_backend().image_attach(str(png), (1.0, 2.0), 0.5, 180.0) + assert result["handle"] == "B2" + name, insert, scale, rotation = doc.ModelSpace.rasters[0] + assert Path(name).name == "logo.png" + assert insert[:2] == (1.0, 2.0) + assert scale == 0.5 + assert rotation == pytest.approx(math.pi) + + +@pytest.mark.asyncio +async def test_com_dwg_export_uses_the_measured_acsaveastype_values(monkeypatch, tmp_path): + import backends.com_backend as cb + + doc = _ComDoc() + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + b = _com_backend() + await b.drawing_export_dwg(str(tmp_path / "a.dwg"), "R2018") + await b.drawing_export_dwg(str(tmp_path / "b.dwg"), "R2000") + assert [entry[1] for entry in doc.saved] == [64, 12] diff --git a/tests/test_sheet_extract.py b/tests/test_sheet_extract.py new file mode 100644 index 0000000..c2ed4a7 --- /dev/null +++ b/tests/test_sheet_extract.py @@ -0,0 +1,89 @@ +"""Parts-list rows to CSV (always) and XLSX (with openpyxl).""" + +from __future__ import annotations + +import csv +from importlib.util import find_spec + +import pytest + +from backends.base import UnsupportedCapabilityError +from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available + + +def _rows(): + return ( + { + "item": 1, + "qty": 2, + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "material": "8.8", + "handles": ("A1", "A2"), + }, + { + "item": 2, + "qty": 1, + "designation": "SHAFT 40h6", + "standard": "", + "material": "C45", + "handles": ("B1",), + }, + ) + + +def test_the_formats_are_closed_and_csv_is_always_one_of_them(): + assert EXTRACT_FORMATS == ("csv", "xlsx") + + +def test_csv_writes_the_heading_row_and_one_row_per_part(tmp_path): + target = tmp_path / "bom.csv" + result = write_rows(_rows(), str(target), fmt="csv") + assert result["ok"] is True and result["rows"] == 2 and result["format"] == "csv" + with open(target, newline="", encoding="utf-8") as handle: + written = list(csv.reader(handle)) + assert written[0] == ["POS", "QTY", "DESIGNATION", "STANDARD", "MATERIAL"] + assert written[1] == ["1", "2", "ISO 4014 - M12x60", "ISO 4014", "8.8"] + assert written[2][2] == "SHAFT 40h6" + assert len(written) == 3 + + +def test_a_chosen_column_set_is_honoured(tmp_path): + target = tmp_path / "short.csv" + write_rows(_rows(), str(target), columns=("item", "designation"), fmt="csv") + with open(target, newline="", encoding="utf-8") as handle: + written = list(csv.reader(handle)) + assert written[0] == ["POS", "DESIGNATION"] + assert all(len(row) == 2 for row in written) + + +def test_an_unknown_format_is_refused_with_the_list(tmp_path): + with pytest.raises(ValueError) as excinfo: + write_rows(_rows(), str(tmp_path / "x.ods"), fmt="ods") + message = str(excinfo.value) + assert "ods" in message and "csv" in message and "xlsx" in message + + +def test_no_rows_is_refused_rather_than_writing_an_empty_file(tmp_path): + target = tmp_path / "empty.csv" + with pytest.raises(ValueError): + write_rows((), str(target), fmt="csv") + assert not target.exists() + + +def test_xlsx_available_agrees_with_the_installed_package(): + assert xlsx_available() is (find_spec("openpyxl") is not None) + + +def test_xlsx_refuses_with_the_capability_key_and_names_the_extra(tmp_path): + if xlsx_available(): + result = write_rows(_rows(), str(tmp_path / "bom.xlsx"), fmt="xlsx") + assert result["ok"] is True and (tmp_path / "bom.xlsx").exists() + return + target = tmp_path / "bom.xlsx" + with pytest.raises(UnsupportedCapabilityError) as excinfo: + write_rows(_rows(), str(target), fmt="xlsx") + assert excinfo.value.capability == "xlsx_write" + assert "openpyxl" in str(excinfo.value) + assert "csv" in str(excinfo.value), "a refusal that does not name the way out is half a refusal" + assert not target.exists() diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index b1cff77..508c513 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 (210 tools, 23 groups). + """Frozen snapshot of the surface (215 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 @@ -537,6 +537,15 @@ async def test_tool_group_sizes_are_unchanged(): It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and `balloon_add` joined the same section; `bom_extract` also carries `query`, but `engineering` outranks it in `_GROUP_TAG_PRIORITY`. + The section's last five tools file elsewhere on purpose: they are + whole-drawing and entity operations, not sheet furniture, and + `_GROUP_TAG_PRIORITY` files them where a caller would look. + `xref_attach` and `xref_manage` carry `drawing` *and* `block`, and `block` + outranks `drawing`, so they file under `blocks` (9 -> 11) beside + `block_insert` -- which is where an xref belongs: it is a block definition + that lives in another file. `image_attach` carries `create` + (entity_creation 18 -> 19), `data_extract` carries `engineering` + (16 -> 17) and `drawing_export_dwg` carries `drawing` alone (11 -> 12). Every other number here has been unchanged since the snapshot was taken. """ @@ -544,12 +553,12 @@ async def test_tool_group_sizes_are_unchanged(): assert sizes == { "analysis": 12, "batch": 3, - "blocks": 9, + "blocks": 11, "corner_ops": 4, "dimensions": 5, - "drawing": 11, - "engineering": 16, - "entity_creation": 18, + "drawing": 12, + "engineering": 17, + "entity_creation": 19, "entity_modification": 16, "entity_query": 9, "environment": 22, @@ -566,4 +575,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 210 + assert sum(sizes.values()) == 215 From 301610620c7b2a7354a1d76ce32973d3faceccee Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 23:35:44 +0300 Subject: [PATCH 7/7] fix(task-14): detach an xref out of every space, and stop the live engine inventing facts Three blocking findings on the refs group, all of them the same shape: a number or a refusal the caller was entitled to read as measured. 1. ezdxf xref_manage: the victim scan and the `inserts` count both walked `doc.modelspace()` only, then dropped the definition with `delete_block(safe=False)` -- the flag that suppresses the in-use check. An xref shown on a paper-space sheet was reported as 1 insert where the drawing held 2, and detaching it left that sheet INSERT with no BLOCK behind it: `ezdxf.recover` reports `FIX 103 UNDEFINED_BLOCK: Deleted entity INSERT(#34) without required BLOCK definition` and the repo's own drawing_audit silently deletes the sheet geometry. The scan now walks `doc.blocks`, which is every entity space (model space, each paper-space tab, every block definition) and is exactly what ezdxf's own safe check searches with `doc.query('INSERT[name==...]i')` -- hence the case-insensitive name match. `delete_block` is now `safe=True`, as the plan's Interfaces block pins it, so a future miss raises instead of writing a corrupt file. 2. COM xref_manage(action='list') reported `kind: "attach"` for every row, a literal constant. Re-measured on this seat's registered type library (C:\Program Files\Common Files\Autodesk Shared\acax25*.tlb, "AutoCAD 2025 Type Library", installed with AutoCAD 2026): IAcadBlock's xref members are exactly IsXRef, Path, Name, Reload, Unload, Bind, Detach, XRefDatabase -- no overlay indicator exists on the interface, so an overlay was reported to the client as an attachment. The row now says `kind: None`, the same honesty the adjacent `inserts` field already had, and the contract plus the tool docstring say so. 3. COM xref_attach had no duplicate-block-name guard, although contracts/refs.py and the tool docstring both promise the refusal and ezdxf implements it. Two different part.dwg files, or a re-attach after a repath, silently re-pointed or half-wrote the definition while the payload claimed the new path. Added `_block_names(doc)` (Blocks.Count / Blocks.Item(index) / block.Name -- the members xref_manage already walks) and the refusal fires before AttachExternalReference is dispatched, with the same message the headless engine gives. Lower-cased, because AutoCAD symbol-table names are case-insensitive. Tests: five new ones, each failing against the unfixed backends -- an xref shown on Layout1 is counted and detached with the drawing, the detached file audits clean through ezdxf.recover, delete_block's in-use check is pinned, the live listing reports kind None (against a fake that models only the measured members, so it cannot enshrine an invented one), and the live attach refuses a held name (xref and plain block, either case) without dispatching. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 40 ++++++++++- backends/contracts/refs.py | 15 +++-- backends/ezdxf_backend.py | 47 +++++++++---- server.py | 7 +- tests/test_refs_contract.py | 131 ++++++++++++++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 21 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 0218371..fdef6b2 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -394,6 +394,24 @@ def _require_block_defined(doc, name): return block +def _block_names(doc) -> set[str]: + """Every block name the drawing holds, lower-cased. + + ``doc.Blocks.Count`` / ``doc.Blocks.Item(index)`` / ``block.Name`` are the + measured members ``xref_manage`` already walks. The set is lower-cased + because AutoCAD symbol-table names are case-insensitive, so ``BASE`` and + ``Base`` are one block and re-attaching under either name hits the same + definition. + """ + names: set[str] = set() + for index in range(doc.Blocks.Count): + try: + names.add(str(doc.Blocks.Item(index).Name).lower()) + except Exception: # noqa: BLE001 - a block that cannot name itself cannot collide + continue + return names + + _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} #: `drawing_properties_*` field → `IAcadSummaryInfo` property. @@ -1882,6 +1900,17 @@ class ComBackend(AutoCADBackend): def _sync(): doc = _acad_doc() + # The contract and the tool docstring both promise this refusal, + # and the ezdxf engine has always had it (`if name in doc.blocks`). + # Without it a second attach under a name the drawing already holds + # either re-points the existing definition or fails inside AutoCAD, + # while the payload still claims the new `path` was attached. + # Block names are case-insensitive in AutoCAD, so the comparison is. + if name.lower() in _block_names(doc): + raise ValueError( + f"xref_attach: the drawing already holds a block named {name!r}; " + "detach it first with xref_manage(action='detach')" + ) reference = doc.ModelSpace.AttachExternalReference( str(source), name, @@ -1929,8 +1958,17 @@ class ComBackend(AutoCADBackend): return found if action == "list": + # `kind` is null, not "attach". Measured from the seat's own + # registered type library (acax25*.tlb, "AutoCAD 2025 Type + # Library", shipped with AutoCAD 2026): IAcadBlock's xref + # members are exactly IsXRef, Path, Name, Reload, Unload, + # Bind, Detach, XRefDatabase -- there is no overlay indicator + # anywhere on the interface. An overlay (which xref_attach + # itself creates, kind='overlay' -> bOverlay=True) is + # therefore not knowable here, and a constant "attach" would + # read to the caller as measured. Same honesty as `inserts`. rows = [ - {"name": b.Name, "path": str(b.Path or ""), "kind": "attach", "inserts": None} + {"name": b.Name, "path": str(b.Path or ""), "kind": None, "inserts": None} for b in _xref_blocks() ] rows.sort(key=lambda row: row["name"]) diff --git a/backends/contracts/refs.py b/backends/contracts/refs.py index f05faec..e8df4e2 100644 --- a/backends/contracts/refs.py +++ b/backends/contracts/refs.py @@ -54,11 +54,16 @@ class RefsContract(ABC): """One of :data:`XREF_ACTIONS` on one external reference. ``list`` ignores `name` and returns - ``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``path`` - rewrites the saved path to `new_path` and returns the new one. - ``detach`` removes every insert of the xref and its definition, and - reports ``inserts_removed``. ``reload`` and ``bind`` need a live seat's - xref manager: the headless engine raises + ``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``kind`` and + ``inserts`` are ``None`` on a live seat -- ActiveX's ``IAcadBlock`` + carries no overlay indicator and no per-xref insert count (measured on + the registered type library), and a constant would read as measured. + ``path`` rewrites the saved path to `new_path` and returns the new one. + ``detach`` removes every insert of the xref -- in *every* entity space, + model space and each paper-space layout, because an insert left behind + with no block definition is a corrupt drawing -- then the definition, + and reports ``inserts_removed``. ``inserts`` counts the same set. + ``reload`` and ``bind`` need a live seat's xref manager: the headless engine raises ``UnsupportedCapabilityError("xref_live", ...)`` rather than pretending, which is why the key exists; ``list``/``detach``/``path`` are *not* behind it. diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 582dac3..5e6335e 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2455,6 +2455,30 @@ class EzdxfBackend(AutoCADBackend): # misses every overlay. xref_mask = const.BLK_XREF | const.BLK_XREF_OVERLAY + def _inserts_of(key: str): + """Every INSERT of `key`, paired with the layout that owns it. + + Model space is *not* the drawing. `doc.blocks` is the one + iterable that reaches every entity space -- it yields the + block layouts behind model space and each paper-space tab as + well as the real block definitions -- and it is exactly what + ezdxf's own `delete_block(safe=True)` searches through + `doc.query("INSERT[name==...]i")`. Counting or deleting over + `doc.modelspace()` alone under-reports an xref shown on a + sheet and leaves that INSERT behind with no BLOCK definition. + The name comparison is case-insensitive for the same reason: + the `i` on that query string. + """ + wanted = key.lower() + found = [] + for layout in doc.blocks: + for entity in layout: + if entity.dxftype() != "INSERT": + continue + if str(entity.dxf.name).lower() == wanted: + found.append((layout, entity)) + return found + def _rows(): rows = [] for block in doc.blocks: @@ -2466,11 +2490,7 @@ class EzdxfBackend(AutoCADBackend): "name": block.name, "path": str(block.block.dxf.xref_path or ""), "kind": "overlay" if flags & const.BLK_XREF_OVERLAY else "attach", - "inserts": sum( - 1 - for entity in doc.modelspace() - if entity.dxftype() == "INSERT" and entity.dxf.name == block.name - ), + "inserts": len(_inserts_of(block.name)), } ) rows.sort(key=lambda row: row["name"]) @@ -2491,14 +2511,15 @@ class EzdxfBackend(AutoCADBackend): self._mark_dirty() return {"ok": True, "name": key, "path": str(new_path), "backend": self.name} - victims = [ - entity - for entity in doc.modelspace() - if entity.dxftype() == "INSERT" and entity.dxf.name == key - ] - for entity in victims: - doc.modelspace().delete_entity(entity) - doc.blocks.delete_block(key, safe=False) + victims = _inserts_of(key) + for layout, entity in victims: + layout.delete_entity(entity) + # safe=True, as the plan pins it: the in-use check is the only + # thing standing between a missed insert and a saved drawing whose + # INSERT has no BLOCK behind it (ezdxf's recover reports that as + # `FIX 103 UNDEFINED_BLOCK` and throws the geometry away). If the + # scan above ever misses one, this raises instead of corrupting. + doc.blocks.delete_block(key, safe=True) self._mark_dirty() return { "ok": True, diff --git a/server.py b/server.py index b1a4849..50a5ff2 100644 --- a/server.py +++ b/server.py @@ -8635,8 +8635,11 @@ async def xref_manage( `capability: "xref_live"` rather than pretended, and the refusal says which engine does them. Other refusals: an action outside the five, an unknown xref name, a 'path' action with no `new_path`. On the live engine a listed - row's `inserts` is null — ActiveX reports no per-xref insert count and this - server does not invent one. + row's `inserts` and `kind` are both null — ActiveX's block interface + carries neither a per-xref insert count nor an overlay indicator, and this + server does not invent them. Headlessly both are measured: `inserts` + counts every insert in the drawing, model space and paper space alike, and + `detach` removes exactly those. """ await ctx.info(f"Xref {action} {name or '(all)'}") validated = str(validate_path(new_path)) if new_path else None diff --git a/tests/test_refs_contract.py b/tests/test_refs_contract.py index 33512d2..990e47a 100644 --- a/tests/test_refs_contract.py +++ b/tests/test_refs_contract.py @@ -155,6 +155,69 @@ async def test_path_rewrites_the_saved_path_and_detach_removes_the_block( assert (await backend.xref_manage("", "list", None))["xrefs"] == [] +@pytest.mark.asyncio +async def test_an_xref_shown_on_a_sheet_is_counted_and_detached_with_it(backend, external): + """Model space is not the drawing. + + An xref is most often *shown* on a paper-space sheet. Counting or deleting + over `doc.modelspace()` alone reported 1 insert where the drawing held 2 + and then removed the definition with `safe=False`, leaving the sheet's + INSERT with no BLOCK behind it -- which ezdxf's own recover reports as + `FIX 103 UNDEFINED_BLOCK` and throws the geometry away. + """ + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + doc = backend._doc + doc.layout("Layout1").add_blockref("BASE", (10, 10)) + + listing = await backend.xref_manage("", "list", None) + assert listing["xrefs"][0]["inserts"] == 2 + + detached = await backend.xref_manage("BASE", "detach", None) + assert detached["inserts_removed"] == 2 + assert "BASE" not in doc.blocks + assert [e for e in doc.layout("Layout1") if e.dxftype() == "INSERT"] == [] + + +@pytest.mark.asyncio +async def test_detach_leaves_a_drawing_that_survives_a_round_trip(backend, external, tmp_path): + """The evidence a caller actually cares about: the saved file audits clean.""" + import ezdxf.recover + + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + backend._doc.layout("Layout1").add_blockref("BASE", (10, 10)) + await backend.xref_manage("BASE", "detach", None) + + out = tmp_path / "detached.dxf" + backend._doc.saveas(out) + _, auditor = ezdxf.recover.readfile(str(out)) + assert [f.message for f in auditor.fixes] == [] + assert [e.message for e in auditor.errors] == [] + + +@pytest.mark.asyncio +async def test_the_definition_is_dropped_with_the_in_use_check_on(backend, external): + """`safe=True` is the seatbelt, not decoration. + + The broken version passed `safe=False`, the flag that *suppresses* the + in-use check, so a missed insert became a corrupt file instead of an + error. This pins the check: with the model-space insert gone but the + sheet's still there -- exactly the state the old victim scan produced -- + `delete_block` refuses. + """ + from ezdxf.lldxf.const import DXFBlockInUseError + + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + doc = backend._doc + doc.layout("Layout1").add_blockref("BASE", (10, 10)) + msp = doc.modelspace() + for entity in [e for e in msp if e.dxftype() == "INSERT"]: + msp.delete_entity(entity) + + with pytest.raises(DXFBlockInUseError): + doc.blocks.delete_block("BASE", safe=True) + assert "BASE" in doc.blocks + + @pytest.mark.asyncio async def test_reload_and_bind_refuse_headlessly_with_the_xref_live_key(backend, external): await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") @@ -357,6 +420,74 @@ async def test_com_manage_uses_reload_bind_detach_on_the_block(monkeypatch): assert listing["xrefs"][0]["path"] == "C:/refs/BASE.dwg" +@pytest.mark.asyncio +async def test_com_list_reports_kind_as_unknown_rather_than_inventing_attach(monkeypatch): + """IAcadBlock has no overlay indicator. + + Measured from the seat's own registered type library (acax25*.tlb, + "AutoCAD 2025 Type Library", installed with AutoCAD 2026): the xref + members are IsXRef, Path, Name, Reload, Unload, Bind, Detach, + XRefDatabase -- nothing says attach-vs-overlay. The fake therefore has no + such member either (a fake that models one enshrines the bug), and the + honest row says `None`, the way `inserts` already does. + """ + import backends.com_backend as cb + + doc = _ComDoc([_Block("BASE", path="C:/refs/BASE.dwg")]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + row = (await _com_backend().xref_manage("", "list", None))["xrefs"][0] + assert row["kind"] is None + assert row["inserts"] is None + for invented in ("IsOverlay", "Overlay", "XRefType"): + assert not hasattr(doc.Blocks.Item("BASE"), invented) + + +@pytest.mark.asyncio +async def test_com_attach_refuses_a_name_the_drawing_already_holds(monkeypatch, external): + """The refusal the contract and the tool docstring both promise. + + It has to be on this engine too, and it has to fire *before* + AttachExternalReference is dispatched -- a second attach under a held name + re-points or half-writes the definition while the payload still claims the + new path was attached. + """ + import backends.com_backend as cb + + doc = _ComDoc([_Block("BASE", path="C:/other/BASE.dwg")]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + with pytest.raises(ValueError) as excinfo: + await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + assert "BASE" in str(excinfo.value) + assert "detach" in str(excinfo.value) + assert doc.ModelSpace.attached == [] + + +@pytest.mark.asyncio +async def test_com_attach_refuses_a_plain_block_of_the_same_name_case_insensitively( + monkeypatch, external +): + """AutoCAD symbol-table names are case-insensitive and a collision is a + collision whether or not the block in the way is itself an xref.""" + import backends.com_backend as cb + + doc = _ComDoc([_Block("Base", is_xref=False)]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + with pytest.raises(ValueError): + await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + assert doc.ModelSpace.attached == [] + + +@pytest.mark.asyncio +async def test_com_attach_still_works_when_no_block_of_that_name_exists(monkeypatch, external): + import backends.com_backend as cb + + doc = _ComDoc([_Block("OTHER", is_xref=True)]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + result = await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + assert result["ok"] is True and result["name"] == "BASE" + assert len(doc.ModelSpace.attached) == 1 + + @pytest.mark.asyncio async def test_com_raster_calls_addraster_with_radians(monkeypatch, tmp_path): import math