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