| """Build a resolution-independent :class:`Drawing` for each of the four patterns. |
| |
| Geometry is derived from OpenCV itself (``getObjPoints``) wherever a board is |
| involved, so the rendered layout always matches what the OpenCV detector |
| expects -- including the ChArUco chessboard square parity, which is read from |
| real marker centres and never hardcoded (spec 8.3). |
| |
| Each ``build_*`` function returns a :class:`PatternResult` whose ``drawing`` is |
| the *core* layout (pattern body + margin only). Format-specific decorations |
| (caption band, centre marks) are added later by :func:`compose_single_canvas` |
| (PNG/SVG) or by ``render_pdf`` (A4), because their placement depends on the |
| final canvas. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass, field |
|
|
| import cv2 |
| import numpy as np |
|
|
| from .aruco_utils import get_marker_module_matrix |
| from .drawing import Drawing, Line, Rect, Text |
|
|
| |
| DEFAULT_MARK_LEN_MM = 5.0 |
| |
| |
| CENTER_MARK_QUIET_FRACTION = 0.3 |
| DEFAULT_MARK_WIDTH_MM = 0.2 |
| DEFAULT_CUT_LINE_WIDTH_MM = 0.25 |
| DEFAULT_CAPTION_FONT_MM = 2.6 |
| CAPTION_LINE_SPACING = 1.35 |
| CAPTION_PAD_MM = 2.5 |
| ID_FONT_MAX_MM = 6.0 |
| ID_FONT_MIN_MM = 0.8 |
| INFO_LABEL_FONT_MAX_MM = 4.0 |
| INFO_LABEL_FONT_MIN_MM = 1.2 |
| |
| |
| |
| MULT_SIGN = "×" |
|
|
|
|
| @dataclass |
| class PatternResult: |
| """Everything needed to render a pattern and describe it. |
| |
| ``drawing`` is the core (body + margin) layout. ``summary`` carries computed |
| values for the UI and caption. ``yaml_fields`` / ``yaml_mats`` feed the |
| FileStorage YAML writer. ``warnings`` holds user-facing notes (in Japanese). |
| """ |
|
|
| drawing: Drawing |
| body_w_mm: float |
| body_h_mm: float |
| margin_mm: float |
| yaml_fields: dict |
| yaml_mats: dict |
| summary: dict |
| warnings: list[str] = field(default_factory=list) |
|
|
|
|
| def _obj_xy(marker_obj_points: np.ndarray) -> np.ndarray: |
| """Return a marker's 4 corner ``(x, y)`` coordinates as a ``(4, 2)`` array.""" |
| return np.asarray(marker_obj_points, dtype=float).reshape(-1, 3)[:, :2] |
|
|
|
|
| def _marker_black_rects( |
| dictionary: "cv2.aruco.Dictionary", |
| marker_id: int, |
| x0_mm: float, |
| y0_mm: float, |
| side_mm: float, |
| ) -> list[Rect]: |
| """Lay a marker's module matrix into the square box ``[x0, x0+side]^2``. |
| |
| Adjacent black modules in a row are merged into a single ``Rect`` so that |
| output files stay small and vector edges stay crisp. Row 0 / column 0 of the |
| matrix map to the top / left of the box (no flip; see ``aruco_utils``). |
| """ |
| if side_mm <= 0: |
| return [] |
| mat = get_marker_module_matrix(dictionary, marker_id) |
| n = mat.shape[0] |
| ms = side_mm / n |
| rects: list[Rect] = [] |
| for r in range(n): |
| c = 0 |
| while c < n: |
| if mat[r, c]: |
| run_start = c |
| while c < n and mat[r, c]: |
| c += 1 |
| rects.append( |
| Rect( |
| x0_mm + run_start * ms, |
| y0_mm + r * ms, |
| (c - run_start) * ms, |
| ms, |
| "black", |
| ) |
| ) |
| else: |
| c += 1 |
| return rects |
|
|
|
|
| def _id_label( |
| text: str, |
| cx_mm: float, |
| top_mm: float, |
| band_h_mm: float, |
| max_font_mm: float, |
| ) -> Text | None: |
| """Build a centred, top-anchored monospace ID label inside a safe band. |
| |
| ``top_mm`` / ``band_h_mm`` describe a horizontal strip known to be free of |
| any marker quiet zone. Returns ``None`` if the strip is too thin to hold a |
| legible label, so callers can record that the label was skipped. |
| """ |
| font = min(band_h_mm * 0.85, max_font_mm) |
| if font < ID_FONT_MIN_MM: |
| return None |
| return Text( |
| cx_mm, |
| top_mm + (band_h_mm - font) * 0.5, |
| text, |
| font, |
| h_anchor="middle", |
| v_anchor="top", |
| monospace=True, |
| ) |
|
|
|
|
| def _bottom_right_label( |
| draw: Drawing, body_w: float, body_h: float, margin_mm: float, text: str |
| ) -> bool: |
| """Place a right-aligned info label in the bottom margin, below the board. |
| |
| Used by the chessboard and ChArUco builders to print the values an OpenCV |
| detector needs (inner-corner count plus the relevant lengths). It lives in |
| the bottom quiet margin so it never overlaps the board. The font scales with |
| the margin but is also shrunk to fit the canvas width; the label is omitted |
| only when that would make it illegible. Returns whether a label was added. |
| """ |
| inset = margin_mm * 0.2 |
| avail_w = body_w + 2 * margin_mm - 2 * inset |
| |
| fit_w = avail_w / max(1.0, len(text) * 0.6) |
| font = min(margin_mm * 0.5, fit_w, INFO_LABEL_FONT_MAX_MM) |
| if font < INFO_LABEL_FONT_MIN_MM: |
| return False |
| draw.add( |
| Text( |
| body_w + 2 * margin_mm - inset, |
| margin_mm + body_h + margin_mm / 2.0, |
| text, |
| font, |
| h_anchor="end", |
| v_anchor="middle", |
| ) |
| ) |
| return True |
|
|
|
|
| |
| def build_aruco_single( |
| dictionary: "cv2.aruco.Dictionary", |
| dict_name: str, |
| marker_id: int, |
| marker_length_mm: float, |
| margin_mm: float, |
| show_id: bool, |
| add_quiet_zone: bool, |
| ) -> PatternResult: |
| """ArUco single marker. Input is ``marker_length_mm`` (the marker itself, not |
| including margin). The canvas is ``marker_length_mm + 2*margin_mm`` square.""" |
| if marker_length_mm <= 0: |
| raise ValueError(f"markerLength ({marker_length_mm} mm) は正の値にしてください。") |
|
|
| total_mm = marker_length_mm + 2.0 * margin_mm |
| warnings: list[str] = [] |
| n = int(dictionary.markerSize) + 2 |
| module_mm = marker_length_mm / n |
| if add_quiet_zone and margin_mm < module_mm: |
| warnings.append( |
| f"余白 {margin_mm:.2f}mm が 1 モジュール幅 {module_mm:.2f}mm 未満です。" |
| "検出用の quiet zone を確保するため余白を広げることを推奨します。" |
| ) |
|
|
| draw = Drawing(total_mm, total_mm) |
| draw.extend(_marker_black_rects(dictionary, marker_id, margin_mm, margin_mm, marker_length_mm)) |
|
|
| if show_id: |
| label = _id_label( |
| f"id={marker_id}", |
| cx_mm=total_mm / 2.0, |
| top_mm=margin_mm + marker_length_mm, |
| band_h_mm=margin_mm, |
| max_font_mm=ID_FONT_MAX_MM, |
| ) |
| if label is not None: |
| draw.add(label) |
| else: |
| warnings.append("余白が狭いため ID ラベルを省略しました。") |
|
|
| summary = { |
| "pattern": "aruco_single", |
| "dictionary": dict_name, |
| "id": marker_id, |
| "markerLength": marker_length_mm, |
| "total": total_mm, |
| "margin": margin_mm, |
| } |
| yaml_fields = { |
| "pattern_type": "aruco_single", |
| "unit": "mm", |
| "dictionary": dict_name, |
| "id": int(marker_id), |
| "markerLength": float(marker_length_mm), |
| "margin": float(margin_mm), |
| } |
| return PatternResult(draw, marker_length_mm, marker_length_mm, margin_mm, yaml_fields, {}, summary, warnings) |
|
|
|
|
| |
| def build_gridboard( |
| dictionary: "cv2.aruco.Dictionary", |
| dict_name: str, |
| markers_x: int, |
| markers_y: int, |
| marker_length: float, |
| marker_separation: float, |
| first_marker: int, |
| margin_mm: float, |
| show_ids: bool, |
| add_quiet_zone: bool, |
| marker_ids: list[int] | None = None, |
| ) -> PatternResult: |
| """ArUco GridBoard. Marker ids are passed to OpenCV as an explicit ids array |
| (4.13's ``GridBoard`` constructor dropped the ``firstMarker`` parameter; its |
| 5th positional arg is now the ids Mat). |
| |
| If ``marker_ids`` is given it is used verbatim (arbitrary, possibly |
| non-consecutive ids); otherwise ids are consecutive from ``first_marker``.""" |
| n_markers = markers_x * markers_y |
| if marker_ids is not None: |
| ids = np.asarray(marker_ids, dtype=np.int32).reshape(-1, 1) |
| if ids.size != n_markers: |
| raise ValueError( |
| f"GridBoard には {n_markers} 個の ID が必要ですが {ids.size} 個指定されました。" |
| ) |
| else: |
| ids = np.arange(first_marker, first_marker + n_markers, dtype=np.int32).reshape(-1, 1) |
| board = cv2.aruco.GridBoard( |
| (markers_x, markers_y), float(marker_length), float(marker_separation), dictionary, ids |
| ) |
| obj_points = board.getObjPoints() |
| board_ids = np.asarray(board.getIds(), dtype=np.int32).ravel() |
|
|
| body_w = markers_x * marker_length + (markers_x - 1) * marker_separation |
| body_h = markers_y * marker_length + (markers_y - 1) * marker_separation |
| draw = Drawing(body_w + 2 * margin_mm, body_h + 2 * margin_mm) |
|
|
| warnings: list[str] = [] |
| module_mm = marker_length / (int(dictionary.markerSize) + 2) |
| if add_quiet_zone and marker_separation < module_mm: |
| warnings.append( |
| f"マーカー間隔 {marker_separation:.2f}mm が 1 モジュール幅 " |
| f"{module_mm:.2f}mm 未満です。検出が不安定になる恐れがあります。" |
| ) |
|
|
| skipped_label = False |
| for mk, mid in zip(obj_points, board_ids): |
| xy = _obj_xy(mk) |
| min_x, min_y = xy.min(axis=0) |
| max_x, max_y = xy.max(axis=0) |
| side = max_x - min_x |
| x0 = margin_mm + min_x |
| y0 = margin_mm + min_y |
| draw.extend(_marker_black_rects(dictionary, int(mid), x0, y0, side)) |
|
|
| if show_ids: |
| |
| |
| |
| is_bottom_row = (max_y + marker_separation) > body_h |
| gap_below = margin_mm if is_bottom_row else marker_separation |
| clearance_top = module_mm |
| clearance_bottom = 0.0 if is_bottom_row else module_mm |
| band_top = y0 + side + clearance_top |
| band_h = max(0.0, gap_below - clearance_top - clearance_bottom) |
| label = _id_label(str(int(mid)), x0 + side / 2.0, band_top, band_h, max_font_mm=side * 0.25) |
| if label is not None: |
| draw.add(label) |
| else: |
| skipped_label = True |
| if show_ids and skipped_label: |
| warnings.append("一部のマーカーは間隔が狭いため ID ラベルを省略しました。") |
|
|
| summary = { |
| "pattern": "gridboard", |
| "dictionary": dict_name, |
| "markersX": markers_x, |
| "markersY": markers_y, |
| "markerLength": marker_length, |
| "markerSeparation": marker_separation, |
| "firstMarker": int(board_ids[0]), |
| "ids": board_ids.tolist(), |
| "margin": margin_mm, |
| "bodyW": body_w, |
| "bodyH": body_h, |
| } |
| yaml_fields = { |
| "pattern_type": "gridboard", |
| "unit": "mm", |
| "dictionary": dict_name, |
| "markersX": int(markers_x), |
| "markersY": int(markers_y), |
| "markerLength": float(marker_length), |
| "markerSeparation": float(marker_separation), |
| "firstMarker": int(board_ids[0]), |
| "margin": float(margin_mm), |
| } |
| yaml_mats = {"ids": board_ids.reshape(1, -1)} |
| return PatternResult(draw, body_w, body_h, margin_mm, yaml_fields, yaml_mats, summary, warnings) |
|
|
|
|
| |
| def build_charuco( |
| dictionary: "cv2.aruco.Dictionary", |
| dict_name: str, |
| squares_x: int, |
| squares_y: int, |
| square_length: float, |
| marker_length: float, |
| margin_mm: float, |
| show_ids: bool, |
| add_quiet_zone: bool, |
| first_marker: int = 0, |
| marker_ids: list[int] | None = None, |
| ) -> PatternResult: |
| """ChArUco board. Marker placement and chessboard parity are both derived |
| from ``getObjPoints``. The board always uses OpenCV's modern (post-4.6) |
| pattern -- the legacy pattern is intentionally not supported. |
| |
| Marker ids: if ``marker_ids`` is given it is used verbatim (arbitrary, |
| possibly non-consecutive); otherwise ids are consecutive from |
| ``first_marker``. Both go through an explicit ids array -- the same |
| mechanism used for GridBoard.""" |
| if square_length <= 0: |
| raise ValueError(f"squareLength ({square_length}mm) は正の値にしてください。") |
| if marker_length >= square_length: |
| raise ValueError( |
| f"markerLength ({marker_length}mm) は squareLength " |
| f"({square_length}mm) より小さくしてください。" |
| ) |
| board = cv2.aruco.CharucoBoard( |
| (squares_x, squares_y), float(square_length), float(marker_length), dictionary |
| ) |
| |
| count = len(board.getObjPoints()) |
| ids: np.ndarray | None = None |
| if marker_ids is not None: |
| ids = np.asarray(marker_ids, dtype=np.int32).reshape(-1, 1) |
| if ids.size != count: |
| raise ValueError( |
| f"この ChArUco ボードには {count} 個の ID が必要ですが {ids.size} 個指定されました。" |
| ) |
| elif first_marker != 0: |
| ids = np.arange(first_marker, first_marker + count, dtype=np.int32).reshape(-1, 1) |
| if ids is not None: |
| board = cv2.aruco.CharucoBoard( |
| (squares_x, squares_y), float(square_length), float(marker_length), dictionary, ids |
| ) |
| obj_points = board.getObjPoints() |
| board_ids = np.asarray(board.getIds(), dtype=np.int32).ravel() |
|
|
| body_w = squares_x * square_length |
| body_h = squares_y * square_length |
| draw = Drawing(body_w + 2 * margin_mm, body_h + 2 * margin_mm) |
|
|
| |
| |
| centres = [(_obj_xy(mk).mean(axis=0)) for mk in obj_points] |
| gx0, gy0 = int(centres[0][0] // square_length), int(centres[0][1] // square_length) |
| white_parity = (gx0 + gy0) % 2 |
| for j in range(squares_y): |
| for i in range(squares_x): |
| if (i + j) % 2 != white_parity: |
| draw.add( |
| Rect( |
| margin_mm + i * square_length, |
| margin_mm + j * square_length, |
| square_length, |
| square_length, |
| "black", |
| ) |
| ) |
|
|
| for mk, mid in zip(obj_points, board_ids): |
| xy = _obj_xy(mk) |
| min_x, min_y = xy.min(axis=0) |
| max_x, max_y = xy.max(axis=0) |
| side = max_x - min_x |
| draw.extend( |
| _marker_black_rects(dictionary, int(mid), margin_mm + min_x, margin_mm + min_y, side) |
| ) |
|
|
| warnings: list[str] = [] |
| ring = (square_length - marker_length) / 2.0 |
| if add_quiet_zone and ring < marker_length / (int(dictionary.markerSize) + 2): |
| warnings.append( |
| f"白マス内の quiet zone (リング幅 {ring:.2f}mm) が狭いため、" |
| "markerLength を小さくすると検出が安定します。" |
| ) |
| if show_ids: |
| |
| |
| |
| warnings.append("ChArUco の ID 併記は quiet zone 保護のため省略しています(ID は YAML に出力されます)。") |
|
|
| inner_x, inner_y = squares_x - 1, squares_y - 1 |
|
|
| |
| |
| |
| _bottom_right_label( |
| draw, body_w, body_h, margin_mm, |
| f"inner {inner_x}{MULT_SIGN}{inner_y} | square {square_length:g} mm | marker {marker_length:g} mm", |
| ) |
|
|
| summary = { |
| "pattern": "charuco", |
| "dictionary": dict_name, |
| "squaresX": squares_x, |
| "squaresY": squares_y, |
| "squareLength": square_length, |
| "markerLength": marker_length, |
| "firstMarker": int(board_ids[0]), |
| "ids": board_ids.tolist(), |
| "margin": margin_mm, |
| "bodyW": body_w, |
| "bodyH": body_h, |
| "innerCornersX": inner_x, |
| "innerCornersY": inner_y, |
| } |
| yaml_fields = { |
| "pattern_type": "charuco", |
| "unit": "mm", |
| "dictionary": dict_name, |
| "squaresX": int(squares_x), |
| "squaresY": int(squares_y), |
| "squareLength": float(square_length), |
| "markerLength": float(marker_length), |
| "firstMarker": int(board_ids[0]), |
| "margin": float(margin_mm), |
| } |
| yaml_mats = {"ids": board_ids.reshape(1, -1)} |
| return PatternResult(draw, body_w, body_h, margin_mm, yaml_fields, yaml_mats, summary, warnings) |
|
|
|
|
| def charuco_marker_count( |
| dictionary: "cv2.aruco.Dictionary", squares_x: int, squares_y: int |
| ) -> int: |
| """Number of ArUco markers a ChArUco board of this size carries. |
| |
| Independent of the marker/square lengths, so dummy valid lengths are used. |
| Lets the UI validate an explicit id list up front. |
| """ |
| board = cv2.aruco.CharucoBoard((squares_x, squares_y), 10.0, 7.0, dictionary) |
| return len(board.getObjPoints()) |
|
|
|
|
| |
| def build_chessboard( |
| squares_x: int, |
| squares_y: int, |
| square_length: float, |
| margin_mm: float, |
| ) -> PatternResult: |
| """Plain checkerboard. The top-left square is white; ``(i+j)`` odd is black. |
| |
| Reports both square count and inner-corner count to avoid the classic |
| ``findChessboardCorners`` mix-up (spec 8.4). |
| """ |
| body_w = squares_x * square_length |
| body_h = squares_y * square_length |
| draw = Drawing(body_w + 2 * margin_mm, body_h + 2 * margin_mm) |
| for j in range(squares_y): |
| for i in range(squares_x): |
| if (i + j) % 2 == 1: |
| draw.add( |
| Rect( |
| margin_mm + i * square_length, |
| margin_mm + j * square_length, |
| square_length, |
| square_length, |
| "black", |
| ) |
| ) |
|
|
| inner_x, inner_y = squares_x - 1, squares_y - 1 |
|
|
| |
| |
| _bottom_right_label( |
| draw, body_w, body_h, margin_mm, |
| f"inner {inner_x}{MULT_SIGN}{inner_y} | square {square_length:g} mm", |
| ) |
|
|
| summary = { |
| "pattern": "chessboard", |
| "squaresX": squares_x, |
| "squaresY": squares_y, |
| "squareLength": square_length, |
| "innerCornersX": inner_x, |
| "innerCornersY": inner_y, |
| "margin": margin_mm, |
| "bodyW": body_w, |
| "bodyH": body_h, |
| } |
| yaml_fields = { |
| "pattern_type": "chessboard", |
| "unit": "mm", |
| "squaresX": int(squares_x), |
| "squaresY": int(squares_y), |
| "innerCornersX": int(inner_x), |
| "innerCornersY": int(inner_y), |
| "squareLength": float(square_length), |
| "margin": float(margin_mm), |
| } |
| return PatternResult(draw, body_w, body_h, margin_mm, yaml_fields, {}, summary, []) |
|
|
|
|
| |
| def _format_ids(ids: list[int]) -> str: |
| """Compact description of a marker-id set: a range if consecutive, else a |
| (possibly truncated) list, always with the total count.""" |
| consecutive = all(ids[i + 1] - ids[i] == 1 for i in range(len(ids) - 1)) |
| if consecutive: |
| return f"{ids[0]}..{ids[-1]} (consecutive, {len(ids)} total)" |
| shown = ", ".join(str(x) for x in ids) |
| if len(shown) > 60: |
| shown = ", ".join(str(x) for x in ids[:10]) + ", ..." |
| return f"{shown} (custom, {len(ids)} total)" |
|
|
|
|
| def build_caption_lines( |
| result: PatternResult, |
| opencv_version: str, |
| timestamp: str, |
| dpi: int | None = None, |
| ) -> list[str]: |
| """Compose the footer caption lines (spec 11). Only present fields appear. |
| |
| All caption text is English (ASCII) so it renders correctly in every output |
| format: the PNG font (DejaVu) and the PDF base-14 fonts have no CJK glyphs, |
| which would otherwise turn Japanese text into tofu boxes.""" |
| s = result.summary |
| pt = s["pattern"] |
| label = { |
| "aruco_single": "ArUco single marker", |
| "gridboard": "ArUco GridBoard", |
| "charuco": "ChArUco board", |
| "chessboard": "Chessboard", |
| }[pt] |
|
|
| lines: list[str] = [f"Type: {label}"] |
| if pt == "aruco_single": |
| lines.append(f"Dictionary: {s['dictionary']} / id: {s['id']}") |
| lines.append(f"markerLength: {s['markerLength']:.3g} mm / total: {s['total']:.3g} mm") |
| elif pt == "gridboard": |
| lines.append(f"Dictionary: {s['dictionary']} / {s['markersX']}x{s['markersY']} markers") |
| lines.append( |
| f"markerLength: {s['markerLength']:.3g} mm / separation: {s['markerSeparation']:.3g} mm" |
| ) |
| lines.append(f"id: {_format_ids(s['ids'])}") |
| elif pt == "charuco": |
| lines.append(f"Dictionary: {s['dictionary']} / {s['squaresX']}x{s['squaresY']} squares") |
| lines.append( |
| f"squareLength: {s['squareLength']:.3g} mm / markerLength: {s['markerLength']:.3g} mm" |
| ) |
| lines.append(f"inner corners: {s['innerCornersX']}x{s['innerCornersY']}") |
| lines.append(f"id: {_format_ids(s['ids'])}") |
| elif pt == "chessboard": |
| lines.append(f"{s['squaresX']}x{s['squaresY']} squares / squareLength: {s['squareLength']:.3g} mm") |
| lines.append(f"inner corners: {s['innerCornersX']}x{s['innerCornersY']}") |
|
|
| tail = f"margin: {s['margin']:.3g} mm" |
| if dpi is not None: |
| tail += f" / DPI: {dpi}" |
| tail += f" / OpenCV {opencv_version} / {timestamp}" |
| lines.append(tail) |
| return lines |
|
|
|
|
| |
| def compose_single_canvas( |
| result: PatternResult, |
| center_marks: bool, |
| caption_lines: list[str] | None, |
| cut_lines: bool = False, |
| mark_len_mm: float | None = None, |
| mark_width_mm: float = DEFAULT_MARK_WIDTH_MM, |
| caption_font_mm: float = DEFAULT_CAPTION_FONT_MM, |
| cut_width_mm: float = DEFAULT_CUT_LINE_WIDTH_MM, |
| ) -> Drawing: |
| """Wrap the core drawing into a tight single canvas with optional caption |
| band, centre-alignment ticks, and an outer cut/trim rectangle (used by the |
| PNG and SVG renderers). |
| |
| ``mark_len_mm`` defaults to ``CENTER_MARK_QUIET_FRACTION`` of the margin |
| (quiet zone) so the ticks always stay inside the margin. ``cut_lines`` draws |
| a solid rectangle along the pattern+margin edge (never the caption band).""" |
| core = result.drawing |
| pattern_w, pattern_h = core.width_mm, core.height_mm |
|
|
| band_h = 0.0 |
| if caption_lines: |
| line_pitch = caption_font_mm * CAPTION_LINE_SPACING |
| band_h = 2 * CAPTION_PAD_MM + len(caption_lines) * line_pitch |
|
|
| out = Drawing(pattern_w, pattern_h + band_h, list(core.items)) |
|
|
| |
| if cut_lines: |
| out.extend(_cut_rect_lines(pattern_w, pattern_h, cut_width_mm)) |
|
|
| |
| |
| if center_marks: |
| length = mark_len_mm if mark_len_mm is not None else CENTER_MARK_QUIET_FRACTION * result.margin_mm |
| out.extend(_center_marks(pattern_w, pattern_h, length, mark_width_mm)) |
|
|
| if caption_lines: |
| out.add(Line(0, pattern_h, pattern_w, pattern_h, 0.15, "black")) |
| line_pitch = caption_font_mm * CAPTION_LINE_SPACING |
| y = pattern_h + CAPTION_PAD_MM |
| for ln in caption_lines: |
| out.add(Text(CAPTION_PAD_MM, y, ln, caption_font_mm, "start", "top")) |
| y += line_pitch |
| return out |
|
|
|
|
| def _center_marks(w: float, h: float, length: float, width: float) -> list[Line]: |
| """Four inward ticks at the midpoints of the rectangle ``[0,w] x [0,h]``.""" |
| cx, cy = w / 2.0, h / 2.0 |
| return [ |
| Line(cx, 0, cx, length, width), |
| Line(cx, h - length, cx, h, width), |
| Line(0, cy, length, cy, width), |
| Line(w - length, cy, w, cy, width), |
| ] |
|
|
|
|
| def _cut_rect_lines(w: float, h: float, width: float) -> list[Line]: |
| """Solid rectangle outline along the outer edge of ``[0,w] x [0,h]``. |
| |
| The stroke is inset by half its width so the whole line stays inside the |
| canvas. It marks where to trim the printout; sitting on the pattern+margin |
| boundary, it never enters the quiet zone (which is the margin itself).""" |
| half = width / 2.0 |
| x0, y0, x1, y1 = half, half, w - half, h - half |
| return [ |
| Line(x0, y0, x1, y0, width), |
| Line(x0, y1, x1, y1, width), |
| Line(x0, y0, x0, y1, width), |
| Line(x1, y0, x1, y1, width), |
| ] |
|
|