Source code for mighty_miter.core.unroll.cylinder

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