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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 19:03:14 +03:00
co-authored by Claude Opus 5
parent 2bb8044144
commit 56e6ff7206
12 changed files with 1514 additions and 299 deletions
+42
View File
@@ -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",
]
+337
View File
@@ -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]},
}
+346
View File
@@ -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]},
}