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