# 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)