# 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
"""The datum: circumferential and axial zero for a template (PRD 4, ADR-015).
A miter template is a paper wrap. It is useless unless the fabricator can align it to a
known line on the tube, and the datum is that line. This module turns a cylinder plus an
explicit geometric reference direction into the orthonormal frame the unrolling tiers
measure against.
Units and frames:
Lengths in **millimeters**, angles in **radians** (PRD 7.6). A centimeter reaching
this module is a defect, not a rounding difference — conversion happens once, in
``adapter/units.py``.
Hazard — the reported parametric window is not a datum (PRD 7.14, ADR-015):
Nothing here reads ``vmin``/``vmax``, and nothing may. One untrimmed cylindrical face
reports ``v = [0, 2pi]`` through ``parametricRange()`` and ``[-pi, +pi]`` through
``textureCoordinates``, and trimming the face changes which window
``parametricRange()`` itself reports. A datum taken as ``v - vmin`` shifts by ``pi``
between a trimmed and an untrimmed face of the same tube — half a wrap, 44.925 mm of
arc at ``r = 14.3 mm``. The template still prints, still wraps, and is clocked half a
turn around the tube. The reference direction therefore always arrives as an explicit
3D vector from geometry the user selected or the intersection defined.
Hazard — handedness (PRD 7.1):
A mirrored template looks perfect and fits nothing, and no closure or length check can
detect it (PRD 11.9's Tier A closure error was 0.0000 um for *either* handedness).
Only the physical gate T6 can. The convention is stated once, in
:data:`CIRCUMFERENCE_SIGN`, and asserted by
``tests/unit/test_datum.py::test_counter_clockwise_from_the_datum_is_positive_x``.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from mighty_miter.core.errors import DatumError
from mighty_miter.core.linalg import Vec3
from mighty_miter.core.types import CylinderParameters, Polyline3D
__all__ = [
"CIRCUMFERENCE_SIGN",
"MIN_AXIAL_SPREAD_MM",
"CylinderDatum",
"default_datum",
"unwrap_angles",
]
#: Sign relating template ``x`` to the right-handed rotation angle about the outward axis.
#:
#: PRD 4 fixes ``x`` as increasing **counter-clockwise when viewed along the tube axis in
#: the direction of the outward-pointing end** — that is, with the viewer's line of sight
#: pointing along ``+axis``, so the outward end is in front of the viewer and the axis
#: points away from them.
#:
#: Under that view, the right-handed rotation ``phi = atan2(w . v0, w . u0)`` with
#: ``v0 = axis x u0`` runs **clockwise**: placing ``u0`` on screen-right puts ``v0`` at
#: screen-*down*, so increasing ``phi`` traces right -> down -> left. Counter-clockwise is
#: therefore *decreasing* ``phi``, and ``x = -radius * phi``.
#:
#: **PRD 6.2 v1 stated ``template = (r * phi, z)``, without the sign.** That is the
#: clockwise convention and contradicts PRD 4; it is corrected as
#: ``[CHANGED v1->v2]``. The two differ by a mirror image, which is exactly the PRD 7.1
#: hazard, so the constant is named, exported and tested rather than folded into an
#: expression as a bare minus sign.
CIRCUMFERENCE_SIGN = -1.0
[docs]
@dataclass(frozen=True, slots=True)
class CylinderDatum:
"""An orthonormal frame fixing circumferential and axial zero on a cylinder (PRD 4).
The frame is ``(reference, binormal, axis)``, right-handed by construction:
``binormal = axis x reference``. Template coordinates are read off it by
:meth:`circumferential_angle` (scaled by the radius to give ``x``) and
:meth:`axial_offset` (which gives ``y`` directly); the tiers in
:mod:`mighty_miter.core.unroll` do that pairing.
Construct through :meth:`from_reference_direction` rather than calling the
constructor, so the reference is orthogonalized against the axis and the invariants
are checked once.
Attributes:
origin: A point on the cylinder axis, in millimeters, in world coordinates. Any
axis point serves; it fixes only where axial zero sits, and
:meth:`with_axial_origin` moves it.
axis: Unit vector along the axis, dimensionless, pointing **away from the cut
end** so that template ``y`` increases away from the cut (PRD 4).
reference: Unit vector perpendicular to ``axis``, dimensionless, pointing at
circumferential zero. This is the datum line on the tube.
radius: Cylinder radius in millimeters. Strictly positive.
Hazard:
Handedness (PRD 7.1) and the parametric window (PRD 7.14). See the module
docstring; both are load-bearing here.
Example:
>>> from mighty_miter.core.linalg import Vec3
>>> from mighty_miter.core.types import CylinderParameters
>>> 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)
... )
>>> datum.binormal().as_tuple()
(0.0, 1.0, 0.0)
"""
origin: Vec3
axis: Vec3
reference: Vec3
radius: float
#: Largest tolerated deviation from unit length or from orthogonality, dimensionless.
#:
#: Set at 1e-9 because these vectors arrive from the adapter after a centimeter ->
#: millimeter scaling and a normalization, each of which costs a few ulp; 1e-9 is
#: roughly 1e7 ulp of headroom over that while still catching a genuinely
#: non-perpendicular reference. It is not a geometric tolerance: a reference off by
#: 1e-9 rad displaces the datum by 1.4e-8 mm at r = 14.3 mm, which is six orders
#: below FR-4.1's 0.01 mm budget.
ORTHONORMAL_TOLERANCE = 1e-9
def __post_init__(self) -> None:
"""Check the frame is orthonormal and the radius positive.
Raises:
DatumError: If ``axis`` or ``reference`` is not unit length, if they are not
perpendicular, or if ``radius`` is not strictly positive.
"""
tol = self.ORTHONORMAL_TOLERANCE
if abs(self.axis.norm() - 1.0) > tol:
raise DatumError(
f"datum axis must be a unit vector; |axis| = {self.axis.norm()!r}",
remediation=(
"Build the datum with CylinderDatum.from_reference_direction, "
"which normalizes the axis for you."
),
)
if abs(self.reference.norm() - 1.0) > tol:
raise DatumError(
"datum reference must be a unit vector; "
f"|reference| = {self.reference.norm()!r}",
remediation=(
"Build the datum with CylinderDatum.from_reference_direction, "
"which normalizes the reference for you."
),
)
alignment = self.axis.dot(self.reference)
if abs(alignment) > tol:
raise DatumError(
"datum reference must be perpendicular to the axis; "
f"axis . reference = {alignment!r}",
remediation=(
"Build the datum with CylinderDatum.from_reference_direction, "
"which orthogonalizes the reference against the axis."
),
)
if not self.radius > 0.0:
raise DatumError(
f"cylinder radius must be positive; got {self.radius!r}",
remediation=(
"Check that the host face is cylindrical and that its radius "
"reached core/ in millimeters, not centimeters (PRD 7.6)."
),
)
[docs]
@classmethod
def from_reference_direction(
cls,
cylinder: CylinderParameters,
*,
outward_axis: Vec3,
reference: Vec3,
) -> CylinderDatum:
"""Build a datum from an explicit geometric reference direction (ADR-015).
The reference is projected into the plane perpendicular to the axis and
normalized, so a caller may pass any direction that is not parallel to the axis —
typically the intersection direction of the two tubes, or a user selection
(FR-3.1 "clock to").
Args:
cylinder: The host cylinder, radius in millimeters.
outward_axis: The axis direction pointing **away from the cut end**. Need not
be unit length; it is normalized here. This is *not* necessarily
``cylinder.axis`` — Fusion's axis direction is arbitrary with respect to
the cut, and PRD 7.2 records that the reported orientation must be
measured rather than trusted.
reference: Any direction not parallel to the axis. Its component along the
axis is removed; what remains, normalized, is circumferential zero.
Returns:
A validated, right-handed datum frame.
Raises:
DatumError: If either vector is degenerate, or if ``reference`` is parallel to
the axis and so leaves nothing to project.
Example:
>>> from mighty_miter.core.linalg import Vec3
>>> from mighty_miter.core.types import CylinderParameters
>>> tube = CylinderParameters(Vec3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0), 14.3)
>>> # A reference tilted off-perpendicular is projected back onto the plane.
>>> datum = CylinderDatum.from_reference_direction(
... tube, outward_axis=Vec3(0.0, 0.0, 2.0), reference=Vec3(3.0, 0.0, 9.0)
... )
>>> datum.reference.as_tuple()
(1.0, 0.0, 0.0)
"""
try:
axis_hat = outward_axis.normalized()
except ValueError as exc: # pragma: no cover - defensive, message is the value
raise DatumError(
f"outward_axis is degenerate: {exc}",
remediation=(
"Pass the host face's axis direction; a zero-length vector cannot "
"orient the template."
),
) from exc
# Gram-Schmidt: strip the axial component, keep what points around the tube.
perpendicular = reference - axis_hat.scaled(reference.dot(axis_hat))
residual = perpendicular.norm()
if residual <= cls.ORTHONORMAL_TOLERANCE * max(1.0, reference.norm()):
raise DatumError(
"reference direction is parallel to the axis, so it fixes no "
"circumferential zero; "
f"|reference - (reference . axis) axis| = {residual!r}",
remediation=(
"Pick a datum direction across the tube rather than along it — "
"the intersection direction of the two tubes is the usual choice."
),
)
return cls(
origin=cylinder.origin,
axis=axis_hat,
reference=perpendicular.scaled(1.0 / residual),
radius=cylinder.radius,
)
[docs]
def binormal(self) -> Vec3:
"""Return ``axis x reference``, completing the right-handed frame.
Returns:
The unit vector at ``+90 degrees`` from the reference in the right-handed
sense about the axis — which, per :data:`CIRCUMFERENCE_SIGN`, is at
**negative** template ``x``.
Example:
>>> from mighty_miter.core.linalg import Vec3
>>> from mighty_miter.core.types import CylinderParameters
>>> 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)
... )
>>> datum.binormal().as_tuple()
(0.0, 1.0, 0.0)
"""
return self.axis.cross(self.reference)
[docs]
def with_axial_origin(self, origin: Vec3) -> CylinderDatum:
"""Return the same frame with axial zero moved to ``origin``.
Args:
origin: The new reference point, in millimeters. Only its projection onto the
axis matters; the circumferential zero is unchanged.
Returns:
A new datum. The receiver is unmodified.
Note:
The point is **projected back onto the axis** before it is stored, which is
what makes the promise above true. :attr:`origin` is documented as *a point
on the axis*, and :meth:`circumferential_angle` measures each point's radial
direction from it: storing an off-axis point would shift the line the angles
are measured from, rotating the whole datum. The natural argument here is a
point on the *miter curve* — which lies on the surface, a full radius off the
axis — so this is not a hypothetical. At ``r = 14.3 mm`` an unprojected
surface point rotates the datum by up to 90 degrees, 22.5 mm of arc, which is
the PRD 7.1 class of failure: the template still wraps and is clocked wrong.
Example:
>>> from mighty_miter.core.linalg import Vec3
>>> from mighty_miter.core.types import CylinderParameters
>>> 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)
... )
>>> # A point on the surface at z = 5 moves axial zero to z = 5 and no more.
>>> datum.with_axial_origin(Vec3(10.0, 0.0, 5.0)).origin.as_tuple()
(0.0, 0.0, 5.0)
"""
offset = origin - self.origin
on_axis = self.origin + self.axis.scaled(offset.dot(self.axis))
return CylinderDatum(
origin=on_axis,
axis=self.axis,
reference=self.reference,
radius=self.radius,
)
[docs]
def point_at(self, x_mm: float, y_mm: float) -> Vec3:
r"""Return the point on the cylinder at template coordinates ``(x, y)``.
The inverse of the Tier A unrolling: where
:func:`~mighty_miter.core.unroll.cylinder.unroll_cylinder` flattens the tube,
this wraps the paper back onto it. With :math:`\varphi = x / r` and
:math:`\theta = \sigma\varphi` for :data:`CIRCUMFERENCE_SIGN` :math:`\sigma`,
.. math::
P = O + r(\cos\theta\, u_0 + \sin\theta\, v_0) + y\,a
In words: walk ``x`` millimeters around the tube from the datum line and ``y``
millimeters along it, and report where you land.
This exists so that the preview can draw the datum ruling and the tick positions
**on the model** (FR-9.2, FR-3.5) without restating the handedness convention:
:data:`CIRCUMFERENCE_SIGN` appears here and in
:meth:`circumferential_angle`, and nowhere else.
Args:
x_mm: Arc length from the datum, in millimeters, positive counter-clockwise
as seen looking along the outward axis (PRD 4).
y_mm: Axial distance from the axial reference, in millimeters, positive away
from the cut end.
Returns:
The world-coordinate point, in millimeters, lying exactly on the cylinder.
Example:
>>> from mighty_miter.core.linalg import Vec3
>>> from mighty_miter.core.types import CylinderParameters
>>> 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)
... )
>>> # Round trip: a quarter turn out and back.
>>> point = datum.point_at(0.25 * math.tau * 10.0, 3.0)
>>> [round(v, 9) for v in point.as_tuple()]
[0.0, -10.0, 3.0]
>>> round(datum.circumferential_angle(point), 9) == round(math.pi / 2, 9)
True
"""
theta = CIRCUMFERENCE_SIGN * (x_mm / self.radius)
radial = self.reference.scaled(math.cos(theta)) + self.binormal().scaled(
math.sin(theta)
)
return self.origin + radial.scaled(self.radius) + self.axis.scaled(y_mm)
[docs]
def circumferential_angle(self, point: Vec3) -> float:
r"""Return the signed angle of ``point`` about the axis, in radians.
This is the raw, wrapped angle in :math:`(-\pi, +\pi]`, already carrying
:data:`CIRCUMFERENCE_SIGN` so that increasing angle means increasing template
``x``. Along an ordered curve, pass the sequence through :func:`unwrap_angles`
before scaling to arc length; a curve crossing the datum otherwise jumps by
``2 pi`` (PRD 7.3, seams).
The formula is PRD 6.2's, negated per :data:`CIRCUMFERENCE_SIGN`:
.. math::
\varphi = -\operatorname{atan2}(w \cdot v_0,\; w \cdot u_0),
\qquad w = (P - O) - ((P - O) \cdot a)\,a
In words: drop the point onto the plane perpendicular to the axis, then read off
its angle from the datum direction, measured counter-clockwise as seen by a
viewer looking along the outward axis.
Args:
point: A point in world coordinates, in millimeters. It need not lie on the
cylinder; only its direction from the axis is used, so a point off the
surface returns the angle of its radial projection.
Returns:
The angle in radians, wrapped to :math:`(-\pi, +\pi]`.
Example:
>>> import math
>>> from mighty_miter.core.linalg import Vec3
>>> from mighty_miter.core.types import CylinderParameters
>>> 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)
... )
>>> # Counter-clockwise as seen looking along +axis is toward -binormal.
>>> round(math.degrees(datum.circumferential_angle(Vec3(0.0, -10.0, 0.0))), 6)
90.0
"""
offset = point - self.origin
radial = offset - self.axis.scaled(offset.dot(self.axis))
raw = math.atan2(radial.dot(self.binormal()), radial.dot(self.reference))
return CIRCUMFERENCE_SIGN * raw
[docs]
def axial_offset(self, point: Vec3) -> float:
"""Return the signed distance of ``point`` along the axis from ``origin``.
Args:
point: A point in world coordinates, in millimeters.
Returns:
The offset in millimeters, positive away from the cut end (PRD 4).
Example:
>>> from mighty_miter.core.linalg import Vec3
>>> from mighty_miter.core.types import CylinderParameters
>>> 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)
... )
>>> datum.axial_offset(Vec3(10.0, 0.0, 7.5))
7.5
"""
return (point - self.origin).dot(self.axis)
#: Smallest axial spread of a miter curve, in millimeters, that yields a default datum.
#:
#: 0.01 mm is FR-4.1's template accuracy budget. FR-3.3 requires the default datum to be
#: "reproducible and physically identifiable"; a cut whose deepest and shallowest points
#: sit closer together than the accuracy the template itself claims has no identifiable
#: deepest point, and which sample wins is then decided by stroking noise rather than by
#: geometry. A square cut is the limiting case: every point ties at spread 0, and any
#: choice among them is arbitrary by a full turn. Refusing is strictly better than
#: clocking a template at random (PRD 7.1).
MIN_AXIAL_SPREAD_MM = 0.01
[docs]
def default_datum(
cylinder: CylinderParameters,
curve: Polyline3D,
*,
outward_axis: Vec3,
) -> CylinderDatum:
"""Derive the default datum from the miter curve itself (FR-3.3, FR-3.4).
Circumferential zero is the ruling through the curve point of **maximum** axial
extent — the deepest point of the cut, furthest from the cut end. Axial zero is the
curve point of **minimum** axial extent, the tip. Template ``y`` therefore runs from
0 at the tip to the cut's depth at the datum ruling, and both features are ones a
fabricator can find on the physical tube with no reference to the CAD model.
Args:
cylinder: The host cylinder, in millimeters.
curve: The miter curve in world coordinates, in millimeters, ordered. Only its
points are read; ``closed`` is irrelevant to an extremum.
outward_axis: The axis direction pointing away from the cut end, from
``adapter.surface.outward_axis_for_cut_end``. Need not be unit length.
Returns:
The datum frame, with axial zero already placed at the tip.
Raises:
DatumError: If the curve has fewer than two points, if ``outward_axis`` is
degenerate, or if the curve's axial spread is below
:data:`MIN_AXIAL_SPREAD_MM`.
Note:
This is a *default*, not a substitute for an explicit datum (FR-3.3). Two
templates on one tube derived independently this way are clocked to their own
cuts and not to each other; linking them is FR-3.1's "clock to", which arrives
with milestone M3. A shallow cope also locates its deepest point weakly — the
extremum is quadratic, so near it a large arc-length error costs little axial
distance — and nothing here reports that weakness. Above the spread floor the
default is returned without a precision claim.
Ties are broken by curve order: the first point attaining the extremum wins, so
the result is deterministic for a given stroking, which is what the golden-file
comparison (PRD 13.3 T10) needs.
Example:
>>> from mighty_miter.core.linalg import Vec3
>>> from mighty_miter.core.types import CylinderParameters, Polyline3D
>>> tube = CylinderParameters(Vec3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0), 10.0)
>>> # A saddle-shaped cut: deepest at +x, tip at -x.
>>> curve = Polyline3D(
... (Vec3(10.0, 0.0, 8.0), Vec3(0.0, 10.0, 4.0), Vec3(-10.0, 0.0, 0.0))
... )
>>> datum = default_datum(tube, curve, outward_axis=Vec3(0.0, 0.0, 1.0))
>>> datum.reference.as_tuple()
(1.0, 0.0, 0.0)
>>> datum.origin.as_tuple()
(0.0, 0.0, 0.0)
"""
points = curve.points
if len(points) < 2:
raise DatumError(
f"a miter curve needs at least two points to place a default datum; "
f"got {len(points)}",
remediation=(
"Select the cut edge rather than a vertex, and check the stroking "
"tolerance did not collapse the curve to a single point."
),
)
try:
axis_hat = outward_axis.normalized()
except ValueError as exc:
raise DatumError(
f"outward_axis is degenerate: {exc}",
remediation=(
"Pass the host face's axis oriented away from the cut, from "
"adapter.surface.outward_axis_for_cut_end."
),
) from exc
origin = cylinder.origin
# max()/min() return the FIRST extremal element, which is the documented tie-break.
deepest = max(points, key=lambda p: (p - origin).dot(axis_hat))
tip = min(points, key=lambda p: (p - origin).dot(axis_hat))
spread = (deepest - tip).dot(axis_hat)
if spread < MIN_AXIAL_SPREAD_MM:
raise DatumError(
f"the cut is square to the tube within {MIN_AXIAL_SPREAD_MM} mm "
f"(axial spread {spread:.6f} mm), so it has no deepest point to clock to",
remediation=(
"Specify a datum explicitly — a symmetry plane through the tube axis is "
"the usual choice. A square-cut tube needs no miter template."
),
)
return CylinderDatum.from_reference_direction(
cylinder, outward_axis=axis_hat, reference=deepest - origin
).with_axial_origin(tip)
[docs]
def unwrap_angles(angles: tuple[float, ...]) -> tuple[float, ...]:
r"""Remove :math:`2\pi` discontinuities from an ordered angle sequence (PRD 6.2).
PRD 6.2 requires the circumferential angle to be "unwrapped continuously along the
ordered curve". A miter curve that crosses the datum otherwise jumps a full
circumference — at ``r = 14.3 mm`` that is an 89.85 mm spike in a template whose whole
width is 89.85 mm, so the failure is loud rather than subtle. The seam itself is
PRD 7.3.
Each successive angle is shifted by the multiple of :math:`2\pi` that brings it
closest to its predecessor, so the result is continuous wherever the true curve is,
and the first element is left untouched as the anchor.
Args:
angles: Angles in radians, in curve order. May be empty.
Returns:
The unwrapped angles, same length and same first element.
Note:
A genuine step of more than :math:`\pi` between adjacent samples is
indistinguishable from a wrap and will be "corrected" the wrong way. That is a
sampling-density question, not an algorithm one: the caller must stroke the curve
finely enough that no two adjacent points are more than half a turn apart. At
FR-4.1's 0.01 mm chordal tolerance the spacing is far below that.
Example:
>>> import math
>>> raw = (3.0, -3.0) # a wrap, not a 6-radian jump backwards
>>> unwrapped = unwrap_angles(raw)
>>> round(unwrapped[1], 6) == round(-3.0 + 2 * math.pi, 6)
True
>>> unwrap_angles(())
()
"""
if not angles:
return ()
turn = 2.0 * math.pi
out = [angles[0]]
for angle in angles[1:]:
previous = out[-1]
# round() gives the nearest whole number of turns, breaking ties to even; a tie
# means the step is exactly half a turn, where neither choice is more correct.
out.append(angle - turn * round((angle - previous) / turn))
return tuple(out)