Source code for mighty_miter.core.datum

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