Source code for mighty_miter.core.template

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
#
# Copyright (c) 2026 Jan Hettenkofer

"""Assemble an unrolled miter curve into a printable 1:1 template (PRD 6, FR-6).

This is the last step before paper: it takes a
:class:`~mighty_miter.core.types.TemplatePolyline` in template
coordinates and lays it onto a page together with the marks that make the print
*verifiable* rather than merely intended.

Why the verification furniture is not optional (PRD 8.4):
    Printer drivers scale silently. A template that is 2 % small looks entirely correct
    and produces a tube that does not fit, and the fabricator has no way to notice before
    cutting. So every sheet carries a calibration rectangle of known size (FR-6.6) and a
    circumference check line with its true length printed on it (FR-6.7), and says in
    plain words that they must be measured first. Those marks are the difference between
    a 1:1 claim and a 1:1 guarantee.

Units and frames:
    Template coordinates are millimeters, ``x`` circumferential from the datum and ``y``
    axial away from the cut end (PRD 4). Page coordinates are millimeters from the
    bottom-left of the sheet. The two are related by one translation, computed in
    :class:`TemplateLayout` — there is no scaling anywhere, by design: this is a 1:1
    document and a scale factor is the one thing that must never enter it.

Scope at M2:
    Cylinder-only, single edge, default datum (PRD 17). Implemented here: FR-6.1 miter
    curve, FR-6.2 outline, FR-6.3 datum mark, FR-6.4 circumferential ticks, FR-6.5 wrap
    overlap tab, FR-6.6 calibration rectangle, FR-6.7 circumference check line, FR-6.8
    orientation warning and fiducial, and a reduced FR-6.9 annotation block. The full
    annotation block, the station-and-offset table (FR-6.10) and relief slits (FR-6.11)
    arrive with M9 and M7 respectively.
"""

from __future__ import annotations

import math
from dataclasses import dataclass

from mighty_miter.core.errors import ExportError
from mighty_miter.core.export.pdf import MM_TO_POINTS, PAGE_SIZES_MM, PdfCanvas
from mighty_miter.core.types import TemplatePolyline

__all__ = [
    "CALIBRATION_RECTANGLE_MM",
    "LINE_WEIGHTS_MM",
    "OVERLAP_TAB_MM",
    "TemplateLayout",
    "render_cylinder_template",
]

#: Stroke weights in millimeters, fixed by PRD 8.2. They are a specification, not a
#: preference: the miter curve must be the most prominent line on the sheet because it is
#: the one being cut to, and the ticks must be the least so they cannot be mistaken for it.
LINE_WEIGHTS_MM = {
    "miter": 0.35,
    "datum": 0.5,
    "tick_major": 0.18,
    "tick_minor": 0.18,
    "overlap": 0.25,
    "outline": 0.18,
    "calibration": 0.35,
}

#: Calibration rectangle size in millimeters (FR-6.6), ``(width, height)``.
#:
#: 100 x 50 mm is PRD 8.2's own example and is chosen to be measurable with an ordinary
#: steel rule: large enough that a 1 % scale error shows up as a full millimeter, and
#: asymmetric so that a 90-degree rotation is obvious.
CALIBRATION_RECTANGLE_MM = (100.0, 50.0)

#: How far the template runs past a full wrap, in millimeters (FR-6.5).
#:
#: The overlapping region is drawn dashed so the fabricator can confirm the wrap closed on
#: itself rather than spiralling. 10 mm is enough to see the two lines agree and small
#: enough not to widen the sheet.
OVERLAP_TAB_MM = 10.0

#: Page margin in millimeters. Generous because consumer printers have unprintable borders
#: of 5-10 mm and a template clipped at the edge silently loses part of the miter curve.
DEFAULT_MARGIN_MM = 15.0

#: Circumferential tick spacing in degrees (FR-6.4): long ticks and short ticks.
MAJOR_TICK_DEGREES = 10
MINOR_TICK_DEGREES = 5

#: Tick spacing in degrees at which a tick is **labeled** (FR-6.4).
#:
#: Every tick FR-6.4 requires is still drawn; this controls only the printed numbers. At
#: 20 degrees the labels are 4.99 mm apart at the reference radius of 14.3 mm, against
#: 3.53 mm for the widest three-digit label at 6 pt — so they clear each other by 1.46 mm.
#: At the major-tick spacing of 10 degrees they overlap by 1.03 mm and the row is
#: unreadable, which is what the 2026-09-06 head-tube template printed as. A tube smaller
#: than r = 10.1 mm would overlap again at 20 degrees; making this radius-aware is
#: FR-6.13's problem, since that is the same requirement to label by arc length.
LABELED_TICK_DEGREES = 20

#: Vertical space the annotation block occupies, measured down from the page top, in mm.
#:
#: FR-6.9's block is a title at ``height - DEFAULT_MARGIN_MM`` plus up to four lines at
#: 4 mm intervals starting 5 mm below it, so the lowest baseline sits 21 mm down; 24 mm
#: leaves the descenders clear. **This was missing from the fit calculation entirely**
#: until review R-M2 measured the consequence: a full wrap at ``r = 27 mm`` with a 170 mm
#: axial span passed ``_fit_page`` by 1 mm on A4 portrait, and four of the five annotation
#: lines then printed *inside* the template outline, with "DATUM x = 0" and an annotation
#: line sharing a baseline. The fit reported success, so nothing warned.
ANNOTATION_BLOCK_MM = 24.0

#: Vertical space above the template outline for the tick caption, in millimeters.
#:
#: ``_draw_ticks`` writes "ticks: degrees around the tube from the datum" 2 mm above the
#: outline at 6 pt (2.1 mm). 5 mm covers both with a millimeter to spare, and like
#: :data:`ANNOTATION_BLOCK_MM` it has to be reserved rather than assumed.
TICK_CAPTION_MM = 5.0

#: Height in millimeters of the tick row measured from the template's bottom edge.
#:
#: A 4 mm major tick, a 0.8 mm gap and a 6 pt label (2.1 mm) come to 6.9 mm; 8 mm keeps a
#: millimeter of air above the numbers. Anything else drawn inside the template must clear
#: this, or it prints across the scale — which is what the fiducial and its caption did on
#: the 2026-09-06 head-tube template.
TICK_ROW_HEIGHT_MM = 8.0

#: Font sizes in points for the sheet's text.
_TITLE_PT = 11.0
_BODY_PT = 8.0
_LABEL_PT = 6.0

#: Width of one digit in Helvetica, as a fraction of the font size.
#:
#: Every digit in the base-14 Helvetica metrics is exactly 556/1000 em wide — the font is
#: tabular in the digits, which is why a tick label's width can be computed here without
#: a metrics table. This is used only to center numeric labels; it is wrong for letters,
#: which is why :func:`_digit_text_width_mm` refuses them.
_HELVETICA_DIGIT_EM = 0.556

#: Width of the Helvetica minus sign, as a fraction of the font size (333/1000 em).
_HELVETICA_MINUS_EM = 0.333


[docs] @dataclass(frozen=True, slots=True) class TemplateLayout: """Where the template sits on the page, in millimeters. A pure translation from template coordinates to page coordinates. There is deliberately **no scale factor**: this is a 1:1 document, and the moment a scale enters the layout the calibration rectangle stops proving anything about the miter curve (PRD 8.4). Attributes: origin_x_mm: Page ``x`` of template ``x = 0`` (the datum). origin_y_mm: Page ``y`` of template ``y = 0`` (the axial reference). Example: >>> TemplateLayout(20.0, 100.0).to_page(5.0, -3.0) (25.0, 97.0) """ origin_x_mm: float origin_y_mm: float
[docs] def to_page(self, x_mm: float, y_mm: float) -> tuple[float, float]: """Map a template point onto the page. Args: x_mm: Circumferential position from the datum, in millimeters. y_mm: Axial position, in millimeters. Returns: The ``(x, y)`` page position in millimeters from the bottom-left. """ return (self.origin_x_mm + x_mm, self.origin_y_mm + y_mm)
[docs] def render_cylinder_template( curve: TemplatePolyline, *, name: str, radius_mm: float, page_size: str = "A4", axial_margin_mm: float = 10.0, document_name: str | None = None, created: str | None = None, ) -> bytes: """Render an unrolled cylinder miter curve as a printable 1:1 PDF (FR-6). Args: curve: The unrolled miter curve in template coordinates, from :func:`~mighty_miter.core.unroll.cylinder.unroll_cylinder`. name: Template name, printed on the sheet and stored as the PDF title. Any text in the WinAnsi (CP1252) range, so umlauts and the Scandinavian letters are fine (T15); see ``_escape_text`` in :mod:`mighty_miter.core.export.pdf`. radius_mm: The host tube radius in millimeters, used for the circumference check line (FR-6.7) and the tick spacing (FR-6.4). page_size: A key of :data:`~mighty_miter.core.export.pdf.PAGE_SIZES_MM`. axial_margin_mm: Extra material kept beyond the miter curve's axial extent, in millimeters (FR-6.2). The template is trimmed to the cut line, so this is the spare the fabricator wraps and holds. document_name: Source document, printed in the annotation block (FR-6.9). created: Optional PDF date string. The only variable field (ADR-012); leave ``None`` for byte-deterministic output. Returns: The complete PDF file. Raises: ExportError: If the template does not fit the page, if the radius is not positive, or if any text contains a character CP1252 cannot encode. Note: Orientation is chosen automatically: if the template is wider than it is tall and does not fit portrait, landscape is tried before giving up. A tube of ``r = 14.3 mm`` unrolls to 89.85 mm of circumference, which fits A4 portrait with room for the calibration block. Example: >>> from mighty_miter.core.types import TemplatePoint, TemplatePolyline >>> curve = TemplatePolyline( ... (TemplatePoint(0.0, 0.0), TemplatePoint(45.0, 8.0), TemplatePoint(89.8495, 0.0)) ... ) >>> pdf = render_cylinder_template(curve, name="Stay", radius_mm=14.3) >>> pdf.startswith(b"%PDF-1.4") True """ if not radius_mm > 0.0: raise ExportError( f"tube radius must be positive; got {radius_mm!r} mm", remediation="Check that the host face radius reached core/ in millimeters.", ) if not axial_margin_mm >= 0.0: raise ExportError( f"axial margin cannot be negative; got {axial_margin_mm!r} mm", remediation="Pass zero or a positive margin in millimeters.", ) circumference = 2.0 * math.pi * radius_mm x_min, x_max, y_min, y_max = curve.bounds() # A curve spanning a full wrap gets the overlap tab (FR-6.5); a partial cut does not, # because there is nothing for it to overlap. is_full_wrap = (x_max - x_min) >= circumference - _WRAP_EPSILON_MM tab = OVERLAP_TAB_MM if is_full_wrap else 0.0 content_width = (x_max - x_min) + tab content_height = (y_max - y_min) + 2.0 * axial_margin_mm calibration_block = CALIBRATION_RECTANGLE_MM[1] + 26.0 # rectangle plus its labels canvas, layout = _fit_page( page_size=page_size, content_width=content_width, content_height=content_height, calibration_block=calibration_block, x_min=x_min, y_min=y_min, axial_margin_mm=axial_margin_mm, ) _draw_outline(canvas, layout, x_min, x_max + tab, y_min, y_max, axial_margin_mm) _draw_ticks(canvas, layout, radius_mm, x_min, x_max, y_min, y_max, axial_margin_mm) _draw_datum(canvas, layout, y_min, y_max, axial_margin_mm) _draw_miter_curve(canvas, layout, curve) if is_full_wrap: _draw_overlap_tab(canvas, layout, curve, circumference) _draw_fiducial(canvas, layout, x_min, y_min, axial_margin_mm) _draw_calibration_block(canvas, circumference, radius_mm) _draw_annotations(canvas, name, radius_mm, circumference, document_name) return canvas.to_bytes(title=name, created=created)
#: How close to a full circumference a curve must span to count as a full wrap, in mm. #: #: 0.01 mm is FR-4.1's template budget: a curve short of a wrap by less than the accuracy #: the template claims is, for the fabricator, a full wrap. Larger would call a genuinely #: partial cut a wrap and draw a meaningless overlap tab; smaller would deny the tab to a #: real wrap over float noise. _WRAP_EPSILON_MM = 0.01 def _fit_page( *, page_size: str, content_width: float, content_height: float, calibration_block: float, x_min: float, y_min: float, axial_margin_mm: float, ) -> tuple[PdfCanvas, TemplateLayout]: """Choose an orientation that fits and return the canvas with its layout. Args: page_size: A key of :data:`~mighty_miter.core.export.pdf.PAGE_SIZES_MM`. content_width: Template width including any overlap tab, in millimeters. content_height: Template height including axial margins, in millimeters. calibration_block: Vertical space the calibration furniture needs, in millimeters. x_min: Smallest template ``x``, in millimeters. y_min: Smallest template ``y``, in millimeters. axial_margin_mm: Axial margin, in millimeters. Returns: The empty canvas and the layout placing the template on it. Raises: ExportError: If the template fits neither orientation. The message reports the measured sizes, because "does not fit" without numbers leaves the user guessing which dimension to change. """ if page_size.upper() not in PAGE_SIZES_MM: raise ExportError( f"unknown page size {page_size!r}", remediation="Use one of " + ", ".join(sorted(PAGE_SIZES_MM)) + ".", ) needed_width = content_width + 2.0 * DEFAULT_MARGIN_MM # Everything that shares the sheet has to be reserved, not assumed. The annotation # block hangs from the page top and the tick caption sits above the outline; before # review R-M2 neither was counted, so a template that "fitted" printed through both. needed_height = ( content_height + calibration_block + ANNOTATION_BLOCK_MM + TICK_CAPTION_MM + 2.0 * DEFAULT_MARGIN_MM ) for landscape in (False, True): canvas = PdfCanvas.of_size(page_size, landscape=landscape) if canvas.width_mm >= needed_width and canvas.height_mm >= needed_height: # Template sits above the calibration block, horizontally centered. origin_x = (canvas.width_mm - content_width) / 2.0 - x_min origin_y = DEFAULT_MARGIN_MM + calibration_block + axial_margin_mm - y_min return canvas, TemplateLayout(origin_x, origin_y) portrait = PdfCanvas.of_size(page_size) raise ExportError( f"the template needs {needed_width:.1f} x {needed_height:.1f} mm but " f"{page_size.upper()} is {portrait.width_mm:.1f} x {portrait.height_mm:.1f} mm " "in either orientation", remediation=( "Choose a larger page size, reduce the axial margin, or split the template " "across sheets once tiling ships (PRD 17 M9)." ), ) def _draw_outline( canvas: PdfCanvas, layout: TemplateLayout, x_min: float, x_max: float, y_min: float, y_max: float, margin: float, ) -> None: """Draw the template's cut-out boundary (FR-6.2). Args: canvas: The page being drawn on. layout: Template-to-page mapping. x_min: Left edge in template coordinates, in millimeters. x_max: Right edge, including any overlap tab, in millimeters. y_min: Lowest miter-curve point, in millimeters. y_max: Highest miter-curve point, in millimeters. margin: Axial margin beyond the curve, in millimeters. """ left, bottom = layout.to_page(x_min, y_min - margin) canvas.rectangle( left, bottom, x_max - x_min, (y_max - y_min) + 2.0 * margin, width_mm=LINE_WEIGHTS_MM["outline"], ) def _draw_datum( canvas: PdfCanvas, layout: TemplateLayout, y_min: float, y_max: float, margin: float, ) -> None: """Draw the heavy datum line at ``x = 0`` and label it (FR-6.3). Args: canvas: The page being drawn on. layout: Template-to-page mapping. y_min: Lowest miter-curve point, in millimeters. y_max: Highest miter-curve point, in millimeters. margin: Axial margin, in millimeters. """ bottom = layout.to_page(0.0, y_min - margin) top = layout.to_page(0.0, y_max + margin) canvas.polyline([bottom, top], width_mm=LINE_WEIGHTS_MM["datum"]) canvas.text(top[0] + 1.5, top[1] - 4.0, "DATUM x = 0", size_pt=_LABEL_PT) def _draw_ticks( canvas: PdfCanvas, layout: TemplateLayout, radius_mm: float, x_min: float, x_max: float, y_min: float, y_max: float, margin: float, ) -> None: """Draw circumferential ticks every 5 degrees, labeling every 20 (FR-6.4). Degrees are physically locatable on a circular section, so they are the right label here. FR-6.13 requires arc-length labels instead once sections stop being circular — on an oval a degree is not a findable position, and printing one would be a confident wrong number (PRD 7.12). Why the labels are sparser than the major ticks: FR-6.4 fixes the **tick** spacing at 10 and 5 degrees and says the ticks are labeled in degrees; it does not fix a label on every major tick, and on the reference joint one per major tick is unreadable. At ``r = 14.3 mm`` a 10-degree step is 2.50 mm of paper while a three-digit label at 6 pt is 3.53 mm wide, so consecutive labels overlap by a third of their width. A 20-degree step gives 4.99 mm per label — measured on the 2026-09-06 head-tube template, where the 10-degree labeling printed as an unreadable band. Both tick sizes are unchanged, so nothing is lost for measuring; only the redundant numbers are dropped. Args: canvas: The page being drawn on. layout: Template-to-page mapping. radius_mm: Tube radius in millimeters, converting degrees to arc length. x_min: Left edge of the curve in template coordinates, in millimeters. x_max: Right edge, in millimeters. y_min: Lowest miter-curve point, in millimeters. y_max: Highest miter-curve point, in millimeters. margin: Axial margin, in millimeters. """ per_degree = radius_mm * math.pi / 180.0 base_y = y_min - margin step = MINOR_TICK_DEGREES first = math.floor(x_min / (per_degree * step)) * step last = math.ceil(x_max / (per_degree * step)) * step for degrees in range(first, last + 1, step): x = degrees * per_degree if not (x_min - 1e-9) <= x <= (x_max + 1e-9): continue major = degrees % MAJOR_TICK_DEGREES == 0 height = 4.0 if major else 2.0 start = layout.to_page(x, base_y) canvas.polyline( [start, (start[0], start[1] + height)], width_mm=LINE_WEIGHTS_MM["tick_major" if major else "tick_minor"], ) # The datum ruling carries FR-6.3's own label, and a "0" centered on a 0.5 mm # heavy line is unreadable anyway, so the tick there is drawn but not numbered. # The tick one wrap later IS numbered, deliberately: it is the same ruling on the # tube but the other edge of the paper, and "360" there is what tells the # fabricator the wrap closed where it should. Only the datum's own end is bare. if degrees % LABELED_TICK_DEGREES == 0 and degrees != 0: label = f"{degrees}" canvas.text( start[0] - _digit_text_width_mm(label, _LABEL_PT) / 2.0, start[1] + height + 0.8, label, size_pt=_LABEL_PT, ) top_y = layout.to_page(x_min, y_max + margin)[1] canvas.text( layout.to_page(x_min, 0.0)[0] + 1.0, top_y + 2.0, "ticks: degrees around the tube from the datum", size_pt=_LABEL_PT, ) def _digit_text_width_mm(label: str, size_pt: float) -> float: """Return the printed width of a numeric label, in millimeters. Args: label: The label, digits with an optional leading ``-``. Anything else is a programming error here, not a user input: this exists to center tick numbers. size_pt: Font size in points. Returns: The width in millimeters, from the base-14 Helvetica metrics (:data:`_HELVETICA_DIGIT_EM`, :data:`_HELVETICA_MINUS_EM`), converted with :data:`~mighty_miter.core.export.pdf.MM_TO_POINTS`. Raises: ValueError: If the label contains anything but digits and a leading minus, where the fixed-width assumption does not hold and the result would be wrong without being obviously wrong. Example: >>> round(_digit_text_width_mm("360", 6.0), 4) 3.5306 """ body = label[1:] if label.startswith("-") else label if not body.isdigit(): raise ValueError(f"expected a numeric label; got {label!r}") ems = len(body) * _HELVETICA_DIGIT_EM + ( _HELVETICA_MINUS_EM if label.startswith("-") else 0.0 ) return ems * size_pt / MM_TO_POINTS def _draw_miter_curve( canvas: PdfCanvas, layout: TemplateLayout, curve: TemplatePolyline ) -> None: """Draw the miter curve as a solid line — the line being cut to (FR-6.1). Args: canvas: The page being drawn on. layout: Template-to-page mapping. curve: The unrolled curve. """ canvas.polyline( [layout.to_page(p.x, p.y) for p in curve.points], width_mm=LINE_WEIGHTS_MM["miter"], ) def _draw_overlap_tab( canvas: PdfCanvas, layout: TemplateLayout, curve: TemplatePolyline, circumference: float, ) -> None: """Repeat the start of the curve past a full wrap, dashed (FR-6.5). When the paper is wrapped, this dashed line must land on top of the solid one. If it does not, the wrap is wrong — the template slipped, or the print was scaled — and the fabricator can see that before cutting rather than after. Args: canvas: The page being drawn on. layout: Template-to-page mapping. curve: The unrolled curve. circumference: One full wrap in millimeters. """ x_min = curve.bounds()[0] limit = x_min + OVERLAP_TAB_MM tail = [p for p in curve.points if p.x <= limit] if len(tail) < 2: return canvas.polyline( [layout.to_page(p.x + circumference, p.y) for p in tail], width_mm=LINE_WEIGHTS_MM["overlap"], dash_mm=(2.0, 1.0), ) def _draw_fiducial( canvas: PdfCanvas, layout: TemplateLayout, x_min: float, y_min: float, margin: float, ) -> None: """Draw the asymmetric orientation fiducial (FR-6.8). Hazard: Handedness (PRD 7.1). A mirrored template looks perfect and fits nothing, and the wrap that produces it is a wrap with the printed side *in*. The fiducial is a right triangle, which has no mirror symmetry, so a template wrapped the wrong way is wrong at a glance rather than after a cut. Note: The fiducial carries **no caption**. FR-6.8 v1 printed "PRINTED SIDE OUT" beside it; on the reference joint that ran straight through the tick row and its labels, so the warning obscured the scale it sat on and was itself unreadable (measured 2026-09-06). It was also redundant: the annotation block already says "Wrap with the PRINTED SIDE OUT", and mirrored text on the paper is the thing a reader notices without being told. FR-6.8 is [CHANGED v1->v2] accordingly — the warning stays on the sheet, in the annotation block, and the fiducial stays as the at-a-glance check. Args: canvas: The page being drawn on. layout: Template-to-page mapping. x_min: Left edge in template coordinates, in millimeters. y_min: Lowest miter-curve point, in millimeters. margin: Axial margin, in millimeters. """ corner = layout.to_page(x_min, y_min - margin) size = 6.0 left = corner[0] + 1.0 bottom = corner[1] + TICK_ROW_HEIGHT_MM canvas.polyline( [ (left, bottom), (left + size, bottom), (left, bottom + size * 0.5), (left, bottom), ], width_mm=LINE_WEIGHTS_MM["outline"], ) def _draw_calibration_block(canvas: PdfCanvas, circumference: float, radius_mm: float) -> None: """Draw the calibration rectangle and circumference check line (FR-6.6, FR-6.7). These are what make 1:1 verifiable with a steel rule before anyone cuts metal (PRD 8.4). The instruction to disable print scaling is here too, because PRD 8.2 forbids putting any scaling hint in the file itself — so it has to be on the paper. Args: canvas: The page being drawn on. circumference: One full wrap in millimeters. radius_mm: Tube radius in millimeters. """ width, height = CALIBRATION_RECTANGLE_MM x = DEFAULT_MARGIN_MM y = DEFAULT_MARGIN_MM + 14.0 canvas.rectangle(x, y, width, height, width_mm=LINE_WEIGHTS_MM["calibration"]) canvas.text( x + 3.0, y + height / 2.0, f"{width:.1f} x {height:.1f} mm - measure me before cutting", size_pt=_BODY_PT, ) canvas.text( x + 3.0, y + height / 2.0 - 5.0, "If this is wrong, print again with scaling OFF (100%, not 'fit to page').", size_pt=_LABEL_PT, ) # FR-6.7: a line whose true length is printed on it, in the circumferential direction. check_y = y - 6.0 available = canvas.width_mm - 2.0 * DEFAULT_MARGIN_MM parts = _check_line_divisions(circumference, available) length = circumference / parts canvas.polyline( [(x, check_y), (x + length, check_y)], width_mm=LINE_WEIGHTS_MM["calibration"], ) wrap = ( f"must wrap the {2.0 * radius_mm:.2f} mm tube exactly once" if parts == 1 else f"{parts} of them wrap the {2.0 * radius_mm:.2f} mm tube exactly once" ) canvas.text( x, check_y - 4.0, f"This line must measure {length:.2f} mm and {wrap}.", size_pt=_LABEL_PT, ) def _check_line_divisions(circumference: float, available: float) -> int: """Return how many copies of the drawn check line make one wrap (FR-6.7). The check line is one of the two marks PRD 8.4 relies on to make 1:1 *verifiable rather than merely intended*, and it runs in the circumferential direction — so on a large tube a full circumference is wider than the page. It used to be drawn at full length unconditionally and silently clipped at the MediaBox: review R-M2 measured a 40 mm-radius tube with a 40 mm-wide partial cut passing the fit check and then losing **56 mm of a 251.33 mm line** on A4 portrait, while the caption still read "must measure 251.33 mm". That is worse than no line at all — the fabricator measures the clipped remnant, concludes the printer scaled by 22 %, and either reprints or distrusts a correct template. Drawing a whole sub-multiple keeps the check honest and keeps it *usable*: laying a rule along a 125.66 mm line twice is a real verification, and the caption says so. Args: circumference: One full wrap, in millimeters. Must be positive. available: Page width between the margins, in millimeters. Returns: The smallest positive integer ``n`` for which ``circumference / n`` fits in ``available``. Returns 1 whenever the whole circumference fits, so the common case is unchanged and the golden file's caption still reads "exactly once". Example: >>> _check_line_divisions(89.85, 180.0) # the reference joint fits whole 1 >>> _check_line_divisions(251.33, 180.0) # a 40 mm-radius tube needs halving 2 >>> _check_line_divisions(628.3, 180.0) # a 100 mm tube needs quarters 4 """ parts = 1 while circumference / parts > available: parts += 1 return parts def _draw_annotations( canvas: PdfCanvas, name: str, radius_mm: float, circumference: float, document_name: str | None, ) -> None: """Draw the annotation block (FR-6.9, reduced for M2). Strain is reported as exactly zero with its provenance named as *analytic*, per FR-5.1a and FR-5.5: a cylinder is developable, so Tier A is an exact isometry and the figure is a closed-form fact rather than a measurement. Saying which it is matters — PRD 18 lists "user trusts a bad template" as the risk that a measured figure quoted as analytic would create. Args: canvas: The page being drawn on. name: Template name. radius_mm: Tube radius in millimeters. circumference: One full wrap in millimeters. document_name: Source document name, or ``None``. """ top = canvas.height_mm - DEFAULT_MARGIN_MM canvas.text(DEFAULT_MARGIN_MM, top, name, size_pt=_TITLE_PT) lines = [ f"Cylinder, outer diameter {2.0 * radius_mm:.2f} mm, " f"circumference {circumference:.2f} mm", "Tier A (cylinder, exact). Strain 0.000 % - analytic, developable surface.", "Wrap with the PRINTED SIDE OUT. Align the datum line to the tube's datum mark.", ] if document_name is not None: lines.append(f"Source: {document_name}") for index, line in enumerate(lines): canvas.text(DEFAULT_MARGIN_MM, top - 5.0 - index * 4.0, line, size_pt=_BODY_PT)