From 56e6ff7206f1f680740f9db53ad6818e9ba3decb Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:03:14 +0300 Subject: [PATCH] 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