# 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
r"""Tier A — the exact cylinder unroller (PRD 6.2).
A cylinder is developable, so the unrolling is an **exact isometry**: every length on the
tube is preserved on the paper, and the strain is identically zero. There is no
approximation here and no tolerance to tune — which is what makes Tier A the reference the
general Tier E solver is checked against (PRD 13.3 T3).
Why the geometric form:
PRD 6.2 offers two routes and instructs the implementer to prefer the geometric one —
working from origin, axis and radius rather than reading Fusion's ``(u, v)``
parameters. Fusion's cylinder happens to be parameterized as
:math:`P(u,v) = (r\cos v,\; r\sin v,\; r u)`, so reading the parameters would work
*today*, but it silently welds the unroller to a kernel convention. The geometric form
is portable (G6) and survives a Fusion parameterization change; the equivalence is
asserted in tests, and that assertion is the tripwire (PRD 13.3 T11).
Units and frames:
Lengths in **millimeters**, angles in **radians** (PRD 7.6).
A deliberate departure from PRD 6.7, recorded so M3 does not repeat it per tier:
PRD 6.7 describes datum application as a **post-processing step** — every tier emits a
raw circumferential coordinate, and the datum's position, flip and offset are
subtracted afterwards, once. This module instead takes a
:class:`~mighty_miter.core.datum.CylinderDatum` as an *input* and applies it inside
the map. The intent PRD 6.7 is protecting is met, and
more strongly: :data:`~mighty_miter.core.datum.CIRCUMFERENCE_SIGN` and the datum's
frame appear in exactly two methods of one class, so there is one place to be wrong
rather than five. What is **not** yet met is FR-3.2's flip and offset, which have no
home at M2. When M3 adds them they belong in :mod:`mighty_miter.core.datum`, applied
once to the emitted template coordinates — not in each tier's map, which is the
outcome PRD 6.7 was written to prevent.
Hazard — handedness (PRD 7.1):
Handled once, in :mod:`mighty_miter.core.datum`, via
:data:`~mighty_miter.core.datum.CIRCUMFERENCE_SIGN`. Nothing in this module chooses a
sign. Note that **no closure or length check can catch a mirrored template**: PRD 11.9
measured Tier A closure at 0.0000 um, and it would measure exactly that for the
mirrored map too. Only the physical gate T6 can.
"""
from __future__ import annotations
import math
from mighty_miter.core.datum import CylinderDatum, unwrap_angles
from mighty_miter.core.errors import UnrollError
from mighty_miter.core.types import Polyline3D, TemplatePoint, TemplatePolyline
__all__ = [
"MAX_RADIAL_DEVIATION_MM",
"canonical_wrap",
"unroll_cylinder",
]
#: Largest tolerated distance from a curve point to the cylinder surface, in millimeters.
#:
#: A miter curve is supposed to *lie on* the host face. A point far off it means the
#: caller paired a curve with the wrong face — the two-candidate-host-face hazard
#: (PRD 7.13), where a severing cut yields two faces of identical radius whose areas
#: differ by 0.0112 %, so "the largest" is decided by numerical noise.
#:
#: Set at 0.01 mm, which is FR-4.1's whole template accuracy budget: a curve deviating by
#: more than the budget cannot produce a template that meets it, so accepting it would be
#: quoting an accuracy the geometry does not support. Fusion strokes curves to a chordal
#: tolerance the caller chooses (CLAUDE.md section 3 — in *centimeters*), typically
#: 0.001 mm, so a conforming curve sits an order of magnitude inside this.
MAX_RADIAL_DEVIATION_MM = 0.01
[docs]
def unroll_cylinder(
curve: Polyline3D,
datum: CylinderDatum,
*,
check_on_surface: bool = True,
) -> TemplatePolyline:
r"""Map an ordered 3D curve on a cylinder into template coordinates (PRD 6.2).
For each point :math:`P`, with axis :math:`a`, origin :math:`O`, radius :math:`r` and
datum directions :math:`u_0`, :math:`v_0 = a \times u_0`:
.. math::
d = P - O, \qquad
z = d \cdot a, \qquad
w = d - z\,a, \qquad
\varphi = -\operatorname{atan2}(w \cdot v_0,\; w \cdot u_0)
and the template point is :math:`(r\varphi,\; z)`, with :math:`\varphi` unwrapped
continuously along the curve. In words: measure how far around the tube the point sits
and how far along it, then lay those out as flat ``x`` and ``y`` — which is exactly
what wrapping paper around a tube does, in reverse.
The negation in :math:`\varphi` is :data:`~mighty_miter.core.datum.CIRCUMFERENCE_SIGN`
and is the PRD 4 handedness convention; PRD 6.2 v1 omitted it. See that constant.
Args:
curve: The miter curve, ordered, in world coordinates, in millimeters. Its points
are expected to lie on the cylinder to within
:data:`MAX_RADIAL_DEVIATION_MM`.
datum: The frame fixing circumferential and axial zero, carrying the radius.
check_on_surface: When ``True`` (the default), verify every point lies on the
cylinder and raise if not. Pass ``False`` only to unroll a curve deliberately
off the surface — a projected annotation, say — where the radial deviation is
expected and meaningless.
Returns:
The unrolled curve in template coordinates, in millimeters, placed in the
canonical window by :func:`canonical_wrap`. Always ``closed=False``: a closed
curve on the tube unrolls to an *open* curve on the paper, because cutting the
tube along the datum separates the two ends of the wrap by a full circumference.
For a closed input the curve is cut **at the datum**, so ``x`` runs from ``0`` to
exactly one circumference and the path is geometrically complete without any
renderer having to close it.
Point order is preserved for an open curve. A closed one is re-indexed to begin
at the datum and is emitted left to right, so a curve that travelled the other
way around the tube comes back reversed — the same path, drawn in the opposite
order, which is all a template needs.
Raises:
UnrollError: If the curve has fewer than two points, or if ``check_on_surface`` is
set and any point lies further than :data:`MAX_RADIAL_DEVIATION_MM` from the
cylinder of the given radius.
Note:
The map is an exact isometry, so arc length is preserved to machine precision:
the unrolled length equals the 3D length. That identity is the cheapest available
self-check and is asserted in the tests rather than at runtime, where it would
cost a second pass over every point for a property that cannot fail by
construction.
Example:
>>> import math
>>> from mighty_miter.core.linalg import Vec3
>>> from mighty_miter.core.types import CylinderParameters, Polyline3D
>>> from mighty_miter.core.datum import CylinderDatum
>>> tube = CylinderParameters(Vec3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0), 10.0)
>>> datum = CylinderDatum.from_reference_direction(
... tube, outward_axis=Vec3(0.0, 0.0, 1.0), reference=Vec3(1.0, 0.0, 0.0)
... )
>>> curve = Polyline3D((Vec3(10.0, 0.0, 0.0), Vec3(0.0, -10.0, 5.0)))
>>> flat = unroll_cylinder(curve, datum)
>>> [(round(p.x, 6), round(p.y, 6)) for p in flat.points]
[(0.0, 0.0), (15.707963, 5.0)]
"""
points = curve.points
if len(points) < 2:
raise UnrollError(
f"a miter curve needs at least two points to unroll; got {len(points)}",
remediation=(
"Select at least one complete edge of the cut, and check that the "
"stroking tolerance did not collapse it to a single point."
),
)
if check_on_surface:
_assert_points_lie_on_cylinder(points, datum)
# A closed curve on the tube is an OPEN curve on the paper: unrolling cuts the tube
# along the datum, and the two ends of the wrap land a full circumference apart in x.
# Repeating the first point here and unwrapping the extended run places that final
# point at exactly one wrap from the start, in whichever direction the curve actually
# travelled. The result is emitted with `closed=False` because in the template plane
# it genuinely is open — marking it closed would make TemplatePolyline.length() add a
# segment from the last point back to the first, spanning the whole circumference,
# and would make any renderer draw a line straight across the template.
walk = (*points, points[0]) if curve.closed else points
angles = unwrap_angles(tuple(datum.circumferential_angle(p) for p in walk))
radius = datum.radius
# `+ 0.0` collapses IEEE negative zero to positive zero. A point exactly on the datum
# gives angle -0.0 (CIRCUMFERENCE_SIGN times 0.0), which formats as "-0" and would
# make two byte-identical templates differ in the golden-file comparison
# (PRD 13.3 T10). It changes no value: -0.0 + 0.0 == 0.0 exactly, and x + 0.0 == x
# for every other float, including infinities.
flat = tuple(
TemplatePoint(x=radius * angle + 0.0, y=datum.axial_offset(point) + 0.0)
for angle, point in zip(angles, walk, strict=True)
)
return TemplatePolyline(
points=canonical_wrap(flat, circumference=2.0 * math.pi * radius, closed=curve.closed),
closed=False,
)
def _assert_points_lie_on_cylinder(
points: tuple[object, ...],
datum: CylinderDatum,
) -> None:
"""Raise unless every point sits on the datum's cylinder.
Args:
points: The curve points, in millimeters.
datum: The frame carrying the axis, origin and radius.
Raises:
UnrollError: On the first point deviating by more than
:data:`MAX_RADIAL_DEVIATION_MM`, naming the index and the measured deviation
so the caller can see *which* point and *how far* rather than only that
something failed.
"""
for index, point in enumerate(points):
offset = point - datum.origin # type: ignore[operator]
radial = offset - datum.axis.scaled(offset.dot(datum.axis))
deviation = abs(radial.norm() - datum.radius)
if deviation > MAX_RADIAL_DEVIATION_MM:
raise UnrollError(
f"curve point {index} lies {deviation:.6f} mm off the cylinder of "
f"radius {datum.radius:.6f} mm, exceeding the "
f"{MAX_RADIAL_DEVIATION_MM} mm limit",
remediation=(
"Check that the selected host face is the one the miter curve lies "
"on. A severing cut leaves two candidate faces of near-identical "
"radius and area (PRD 7.13), so the wrong one is easy to pick."
),
)
[docs]
def canonical_wrap(
points: tuple[TemplatePoint, ...],
*,
circumference: float,
closed: bool = False,
) -> tuple[TemplatePoint, ...]:
r"""Place an unrolled run in the canonical template window (PRD 4, FR-6.5).
Unwrapping (:func:`~mighty_miter.core.datum.unwrap_angles`) makes the circumferential
coordinate *continuous*, but it anchors it on the curve's first sample, which is
wherever Fusion's edge happens to start. The result is correct only up to a whole
number of wraps: a full-turn cope on the reference joint came out spanning
:math:`-450^\circ` to :math:`-90^\circ`, so the datum ruling — which PRD 4 fixes at
``x = 0`` — sat one full circumference off the sheet, and FR-6.3's heavy line was
drawn beside the template rather than on it. Adding or subtracting a whole
circumference moves no point on the tube, so the window is free to choose and must be
chosen deliberately.
What this function guarantees:
* **A full wrap** — a closed miter curve, whose run therefore ends one whole
circumference from where it started — is re-indexed to **start at the datum**, so
``x`` runs from ``0`` to exactly one circumference. FR-6.5's overlap tab and the
paper seam then both fall on the datum ruling, which is the deepest point of the
cut under the default datum (FR-3.3).
* **Anything else** — a partial cut, or a closed loop that does not encircle the tube
— is shifted by whole wraps so the datum ruling lands inside the run where one
exists, and as near to it as a whole number of wraps allows where none does. The
point order is left exactly as it came.
Args:
points: The unrolled run in template coordinates, in millimeters, in curve order.
circumference: One full wrap, :math:`2 \pi r`, in millimeters. Must be positive.
closed: Whether ``points`` came from a **closed** curve on the tube, in which case
its last point repeats its first one wrap later. Only such a run may be
re-indexed: rotating a genuinely open run would join two ends that are not
the same point. An open run spanning a full turn by coincidence is therefore
shifted, not rotated.
Returns:
The same curve in the canonical window, in millimeters. Values of ``y`` are never
touched — this function only chooses where the circumferential origin sits.
A full wrap is emitted **left to right**: a curve that travelled the other way
around the tube is reversed, which draws the identical path in the opposite
order. Point order is otherwise preserved, and a full wrap gains one point where
the datum crossing falls between two samples.
Raises:
UnrollError: If ``circumference`` is not positive.
Note:
The datum crossing is interpolated **linearly in the template plane**, not on the
cylinder. That is exact with respect to what is drawn: the renderer joins
successive samples with straight lines, so the interpolated point lies on the
segment the template actually shows. Interpolating on the surface instead would
put the split at a point the drawn polyline does not pass through.
Example:
>>> import math
>>> from mighty_miter.core.types import TemplatePoint
>>> circumference = 2.0 * math.pi * 10.0
>>> # A full wrap whose samples straddle the datum rather than landing on it.
>>> run = tuple(
... TemplatePoint(-circumference / 6.0 + i * circumference / 3.0, float(i))
... for i in range(4)
... )
>>> placed = canonical_wrap(run, circumference=circumference, closed=True)
>>> [round(p.x, 6) for p in placed] # starts and ends on the datum ruling
[0.0, 10.471976, 31.415927, 52.359878, 62.831853]
>>> [round(p.y, 6) for p in placed] # the split point is interpolated
[0.5, 1.0, 2.0, 0.0, 0.5]
"""
if not circumference > 0.0:
raise UnrollError(
f"the wrap circumference must be positive; got {circumference!r} mm",
remediation="Check that the host face radius reached core/ in millimeters.",
)
if len(points) < 2:
return points
xs = [point.x for point in points]
ys = [point.y for point in points]
# A closed miter curve walks the tube exactly once, so its ends differ by one
# circumference; the sign is the direction it happened to travel. Anything else --
# a partial cut, or a closed loop that does not encircle the tube -- winds by zero.
winding = round((xs[-1] - xs[0]) / circumference)
if not closed or abs(winding) != 1:
return _shifted(xs, ys, _partial_shift(xs, circumference))
if winding < 0:
xs.reverse()
ys.reverse()
# The first datum ruling at or after the run's start. The run ends exactly one
# circumference later, so this always lies within it.
start = math.ceil(xs[0] / circumference) * circumference
crossing = next((i for i, x in enumerate(xs) if x >= start), None)
if crossing is None: # pragma: no cover - unreachable while xs[-1] == xs[0] + C
return _shifted(xs, ys, _partial_shift(xs, circumference))
if crossing == 0:
return _shifted(xs, ys, start)
return _rotated(xs, ys, crossing, start, circumference)
def _partial_shift(xs: list[float], circumference: float) -> float:
"""Choose the whole-wrap shift for a run that is not a full wrap.
Args:
xs: Circumferential coordinates, in millimeters.
circumference: One full wrap, in millimeters.
Returns:
The amount to subtract from every ``x``, in millimeters — always a whole number
of circumferences, so no point moves on the tube.
Note:
A run carrying the default datum always contains a datum ruling, because FR-3.3
derives the datum from a point *of the curve*: that point's raw angle is zero, so
its unwrapped ``x`` is a whole number of wraps. The nearest-approach branch is
for an explicit datum (FR-3.1) placed off the cut, where no whole-wrap shift can
bring the ruling onto the sheet and the honest result is the closest one.
"""
low, high = min(xs), max(xs)
first_inside = math.ceil(low / circumference)
if first_inside * circumference <= high:
return first_inside * circumference
return round((low + high) / (2.0 * circumference)) * circumference
def _shifted(xs: list[float], ys: list[float], shift: float) -> tuple[TemplatePoint, ...]:
"""Subtract a whole-wrap shift from every ``x``.
Args:
xs: Circumferential coordinates, in millimeters.
ys: Axial coordinates, in millimeters.
shift: The amount to subtract, in millimeters.
Returns:
The shifted run. ``+ 0.0`` collapses a negative zero, which would otherwise
format as ``-0`` and break the byte comparison in PRD 13.3 T10.
"""
return tuple(TemplatePoint(x=x - shift + 0.0, y=y) for x, y in zip(xs, ys, strict=True))
def _rotated(
xs: list[float],
ys: list[float],
crossing: int,
start: float,
circumference: float,
) -> tuple[TemplatePoint, ...]:
"""Re-index a full wrap so it begins and ends on the datum ruling.
Args:
xs: Circumferential coordinates in millimeters, increasing overall by exactly one
circumference. ``xs[-1]`` repeats ``xs[0]`` one wrap later.
ys: Axial coordinates, in millimeters.
crossing: Index of the first sample at or past ``start``; at least 1.
start: Circumferential coordinate of the datum ruling to cut at, in millimeters.
circumference: One full wrap, in millimeters.
Returns:
The rotated run, from ``x = 0`` to ``x = circumference``, with the split point
interpolated at both ends.
"""
on_sample = xs[crossing] == start
if on_sample:
split_y = ys[crossing]
first = crossing
rotated = []
else:
span = xs[crossing] - xs[crossing - 1]
# A segment of zero circumferential width crosses the datum at an undefined y;
# taking the earlier sample's y is the only reading available, and it is right to
# the width of the segment, which is zero.
fraction = (start - xs[crossing - 1]) / span if span else 0.0
split_y = ys[crossing - 1] + fraction * (ys[crossing] - ys[crossing - 1])
first = crossing
rotated = [TemplatePoint(x=0.0, y=split_y)]
# The loop's unique points are 0 .. len - 2; the last sample repeats the first one
# wrap later, so it is rebuilt as the closing point rather than carried through.
rotated.extend(
TemplatePoint(x=xs[i] - start + 0.0, y=ys[i]) for i in range(first, len(xs) - 1)
)
rotated.extend(
TemplatePoint(x=xs[i] + circumference - start + 0.0, y=ys[i]) for i in range(first)
)
rotated.append(TemplatePoint(x=circumference, y=split_y))
# The [0, C] guarantee this function's caller documents holds because the run is
# monotonic in x, and it is monotonic because the miter curve of a FULL wrap on a
# cylinder crosses every ruling exactly once -- a curve that doubled back would visit
# some ruling twice and could not close in one turn. That is an argument, not a
# measurement, so it is checked rather than trusted: a non-monotonic run would emerge
# with x outside [0, C] and produce an outline wider than one wrap, which the page-fit
# check would then reject with a confusing message instead of this one.
#
# Tolerance: one part in 1e9 of the circumference, which at r = 14.3 mm is 0.09
# nanometers. The arithmetic here is one subtraction and one addition per point, so
# anything larger than a few ulp is a real defect; anything smaller would fail on
# rounding.
slack = circumference * 1e-9
out_of_window = [
point for point in rotated if not -slack <= point.x <= circumference + slack
]
if out_of_window:
raise UnrollError(
f"{len(out_of_window)} point(s) fell outside the canonical wrap window "
f"[0, {circumference:.6f}] mm after re-indexing; the worst is at "
f"x = {max(out_of_window, key=lambda p: abs(p.x)).x:.6f} mm",
remediation=(
"This means the unrolled run was not monotonic in x, which a full wrap "
"on a cylinder cannot be. Check that the selected edge is a single "
"closed miter curve and not two edges joined end to end."
),
)
return tuple(rotated)