Source code for mighty_miter.core.protocols

# 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 contract between ``core/`` and ``adapter/`` (PRD 12.3).

The core declares what it needs as :class:`typing.Protocol` classes; the adapter
satisfies them by wrapping ``BRepFace`` and ``BRepEdge``. Nothing here imports ``adsk``,
and nothing here may (PRD 12.1, ADR-002).

This has a second payoff beyond portability: **the core is testable without Fusion at
all**, because an analytic :class:`SurfaceSource` for a cylinder, cone or torus is about
twenty lines (PRD 13.2). That is what makes the CI strategy of PRD 14 possible.

Units and frames:
    Every length is in **millimeters** and every angle in **radians** (PRD 7.6,
    ADR-011). Fusion works in centimeters; the conversion happens once, in
    ``adapter/units.py``, and never leaks past it. An implementation of these protocols
    that hands the core a centimeter is a defect, not a rounding difference.

Parameterization:
    ``u`` and ``v`` are the surface's own parameters as the underlying kernel defines
    them, not normalized to ``[0, 1]``. For the three analytic types Fusion's
    conventions are measured and recorded in PRD 6.0; do not assume them for a NURBS
    face. The invariant that holds for every surface type is that
    :meth:`SurfaceSource.first_derivatives` yields the first fundamental form directly
    (PRD 6.0), which is why the tiers can share one input.
"""

from __future__ import annotations

from typing import Protocol, runtime_checkable

from mighty_miter.core.linalg import Vec3
from mighty_miter.core.types import AnalyticSurface

__all__ = ["CurveSource", "SurfaceSource"]


[docs] @runtime_checkable class SurfaceSource(Protocol): """A patch of surface, in millimeters and radians, with no CAD kernel attached. Implemented by ``adapter/surface.py`` over a Fusion ``BRepFace``, and by the analytic test doubles in ``tests/fixtures/`` over a closed-form surface. ``runtime_checkable`` is set so a live adapter smoke test (PRD 13.4) can assert that an adapter object structurally satisfies this protocol. Note that :func:`isinstance` against a runtime-checkable protocol checks only that the attribute names exist, never their signatures. """
[docs] def parametric_range(self) -> tuple[float, float, float, float]: """Return the parameter domain of the underlying surface. Returns: ``(u_min, u_max, v_min, v_max)``, in the surface's own parameters. The domain is the whole surface, which for a trimmed face is larger than the face; use :meth:`is_on_face` to test membership. """ ...
[docs] def point_at(self, u: float, v: float) -> Vec3: """Return the surface point at a parameter. Args: u: First surface parameter. v: Second surface parameter. Returns: The point ``P(u, v)`` in millimeters, in the model's world coordinates. """ ...
[docs] def first_derivatives(self, u: float, v: float) -> tuple[Vec3, Vec3]: r"""Return the partial derivatives ``(P_u, P_v)`` at a parameter. This is the foundation of the whole unrolling engine (PRD 6.0): the first fundamental form follows immediately, as :math:`E = P_u \cdot P_u`, :math:`F = P_u \cdot P_v`, :math:`G = P_v \cdot P_v`, for **any** surface type including NURBS. Build the result with :meth:`mighty_miter.core.types.Metric.from_derivatives`. In plain language: how fast and in which direction the surface point moves when each parameter is nudged. Args: u: First surface parameter. v: Second surface parameter. Returns: ``(P_u, P_v)``, in millimeters per unit of the respective parameter. """ ...
[docs] def curvatures(self, u: float, v: float) -> tuple[float, float]: """Return the principal curvatures at a parameter. Used by the classifier, which is a statement about the metric rather than about the type name Fusion reports (PRD 6.1, ADR-005). Gaussian curvature is the product of the two. Sign convention: Both values are signed **against** :meth:`outward_normal_at`. A principal curvature is *positive* where the surface curves away from the outward normal — that is, where the center of the osculating circle lies on the inward side. So a cylinder of radius ``r``, seen from outside, is ``(1 / r, 0.0)``; on the outer equator of a torus both principal curvatures (``1 / r`` around the section and ``1 / R_local`` along the spine) are positive, and on the inner half the spine term goes negative, which is what makes the Gaussian curvature there negative. This is a **frame**, and CLAUDE.md section 9 requires a frame to be stated rather than assumed. Implementations must convert to it; the analytic fixtures in ``tests/fixtures/analytic.py`` are written to it. Hazard: **The convention is asserted here, not yet measured against Fusion** (PRD 7.2, and PRD 7.5 for the bore case). PRD 11.1 verified only that the *Gaussian* curvature ``K = k_max * k_min`` matched the analytic torus value to 0.00e+00, and ``K`` is invariant under a normal flip because both principal curvatures flip together. That measurement therefore does not pin the sign of the individual values. Anything reading ``k_max`` alone — a mean-curvature test, a largest-principal-curvature threshold, or Tier D's mandatory constant-radius gate before an ``r/R`` bound may be quoted (PRD 6.5) — would behave one way against these fixtures in CI and possibly the other way against a real face, which is a plausible, wrong and *green* result. The adapter owes this a ``tests/live/`` smoke test measuring Fusion's actual sign on a cylinder and on both halves of a torus (CLAUDE.md section 5: any API shape the code depends on gets a live smoke test). If the measurement disagrees, the **adapter** normalizes to the convention above; ``core/`` is not told about it. Args: u: First surface parameter. v: Second surface parameter. Returns: ``(k_max, k_min)`` in **1/millimeter**, signed as described above and ordered so that ``k_max >= k_min``. Fusion's ``SurfaceEvaluator.getCurvature`` returns 1/centimeter; converting it is the adapter's job (PRD 7.6). """ ...
[docs] def is_on_face(self, u: float, v: float) -> bool: """Report whether a parameter lies inside the trimmed face. Args: u: First surface parameter. v: Second surface parameter. Returns: ``True`` if ``(u, v)`` is on the face, ``False`` if it is on the underlying surface but outside the trim boundary. """ ...
[docs] def outward_normal_at(self, u: float, v: float) -> Vec3: """Return the unit normal pointing away from the tube axis. Hazard: Face normal direction (PRD 7.2). ``BRepFace.isParamReversed`` genuinely varies between faces in one design, so neither that flag nor the raw evaluator normal may be trusted. The implementation must **measure** ``normal . radial`` and branch on the observed sign. Getting this wrong produces a template that is inside-out — plausible, and a perfect misfit. Bore versus outer surface (PRD 7.5) is a related trap: on the bore of a tube the outward-from-axis direction points into the material. Args: u: First surface parameter. v: Second surface parameter. Returns: A unit vector, dimensionless, guaranteed to point away from the tube axis. """ ...
[docs] def analytic_parameters(self) -> AnalyticSurface | None: """Return the face's closed-form description, or ``None`` if it has none. PRD 6.2 writes Tier A as "given cylinder origin ``O``, unit axis ``a``, radius ``r``..." and PRD 6.3 writes Tier B in terms of ``apex``, ``axis`` and ``half_angle``. This method is the sanctioned route for those values to reach ``core/``. Without it a tier has to recover them by sampling — ``r = |P_v|``, ``a = P_u.normalized()`` — which works only because Fusion happens to parameterize a cylinder as ``u = z/r, v = theta``, and that is precisely the assumption PRD 6.2 tells the implementer not to make: "prefer the geometric form above — it is independent of Fusion's parameterization and therefore portable per G6". Deriving the geometric form *from* the parameterization defeats the reason the PRD asks for it. The metric-first architecture is untouched by this. Tier E needs nothing from here and works from :meth:`first_derivatives` alone (PRD 6.0, ADR-004), so ``None`` is always a workable answer rather than a dead end. Hazard: **Cone apex (PRD 6.3).** :class:`~mighty_miter.core.types.ConeParameters` carries the **apex**, not Fusion's ``Cone.origin``, which is not the apex. The adapter computes it once, at the boundary; a consumer of this method never sees the raw value and so cannot re-enter the trap. See that class for the formula and for why storing the origin here would be a defect. Hazard: **Analytic bound quoted for a face that does not qualify (PRD 6.5, PRD 7.12).** Returning :class:`~mighty_miter.core.types.TorusParameters` asserts that the minor radius is genuinely constant along the face, which is the gate PRD 6.5 makes mandatory before an ``r/R`` strain bound may be claimed. A face that merely looks toroidal — the reference model has one running from 0.03 % to 60.8 % ovality — must return ``None`` and be handled on measured geometry. A confident wrong number is worse than an honest measured one. Returns: A :data:`~mighty_miter.core.types.AnalyticSurface` in millimeters and radians, or ``None`` for a NURBS or otherwise non-analytic face. ``None`` is an explicit, typed answer — "this face has no closed form" is a fact a tier branches on, not an omission it may overlook. """ ...
[docs] @runtime_checkable class CurveSource(Protocol): """A curve lying on a host face — the miter curve, in millimeters and radians. Implemented by ``adapter/curve.py`` over a Fusion ``BRepEdge`` or an edge chain (FR-1), and by analytic test doubles in ``tests/fixtures/``. """
[docs] def parametric_range(self) -> tuple[float, float]: """Return the parameter domain of the curve. Returns: ``(t_min, t_max)`` in the curve's own parameter. """ ...
[docs] def point_at(self, t: float) -> Vec3: """Return the curve point at a parameter. Args: t: Curve parameter, within :meth:`parametric_range`. Returns: The point in millimeters, in the model's world coordinates. """ ...
[docs] def first_derivative(self, t: float) -> Vec3: """Return the tangent vector at a parameter. The vector is **not** normalized; its magnitude is the parametric speed, which callers need in order to integrate arc length. Args: t: Curve parameter, within :meth:`parametric_range`. Returns: ``dP/dt`` in millimeters per unit parameter. """ ...
[docs] def length_between(self, t_start: float, t_end: float) -> float: """Return the exact arc length between two curve parameters, in millimeters. PRD 6.4 Tier C is explicit: "arc length must be computed by integrating along the profile curve, **not** by chord summation. Prefer ``CurveEvaluator3D.getLengthAtParameter``, which is exact for the underlying geometry." This method is that instruction made reachable. The adapter implements it over ``getLengthAtParameter``; the analytic fixtures implement it in closed form. Hazard: **Do not reach for** :meth:`~mighty_miter.core.types.Polyline3D.length` on the output of :meth:`stroke_points`. That is chord summation, and its own docstring admits it underestimates the true arc length by the chordal deviation. The error is systematic, smooth, and *small* — which is the worst magnitude an error can have, because it survives eyeballing and only shows up on the metal. A template short by a predictable fraction of a millimeter is exactly the plausible-but-wrong output this project refuses to produce. Args: t_start: Start parameter, within :meth:`parametric_range`. t_end: End parameter, within :meth:`parametric_range`. May be less than ``t_start``. Returns: The arc length in millimeters. Always **non-negative**: the result is the length of the span, not a signed displacement, so swapping the arguments returns the same number. """ ...
[docs] def is_closed(self) -> bool: """Report whether the curve forms a closed loop. A miter curve is closed for a full cope and open for a partial one; both are in scope (PRD 13.2 requires open miter curves in the test corpus). Returns: ``True`` if the start and end points coincide. """ ...
[docs] def stroke_points(self, tolerance_mm: float) -> tuple[Vec3, ...]: """Return an ordered polyline approximation of the curve. Hazard: Units (PRD 7.6). ``tolerance_mm`` is a chordal deviation in **millimeters**; Fusion's ``CurveEvaluator3D.getStrokes`` takes its tolerance in **centimeters**, so 0.01 mm is ``0.001`` there. The conversion belongs in ``adapter/units.py`` and nowhere else. Args: tolerance_mm: Maximum chordal deviation of the polyline from the true curve, in millimeters. Returns: The ordered points, in millimeters, first point first. """ ...