# 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.
"""
...