mighty_miter.core

The portable layer: geometry and export, in millimeters and radians, with no CAD kernel attached. It must never import adsk (ADR-002).

Portable geometry and export — the layer that knows nothing about Fusion.

Every module in this package works in millimeters and radians (PRD 7.6, ADR-011) and must never import adsk, directly or transitively (PRD 12.1, ADR-002). That rule is what makes the geometry reusable outside Fusion and what makes the majority of the project testable with no CAD kernel present: an analytic SurfaceSource for a cylinder, cone or torus is about twenty lines (PRD 12.3, PRD 13.2).

The rule is enforced mechanically by tools/check_core_purity.py and by tests/unit/test_architecture_gates.py (PRD 13.3 T13), not by discipline.

core.linalg

Small dense linear algebra written by hand, validated against numpy.

Scope at milestone M1 is deliberately narrow: an immutable 3-vector, an immutable 3x3 matrix, and a symmetric 3x3 eigendecomposition. The dense solve, the least-squares fit, the matrix-free conjugate gradient and the geometric fits catalogued in PRD 11.13 are added by the milestones that need them; the Tier E sparse solver in particular is not part of M1.

Why this is hand-written rather than a numpy call: the surface numpy would genuinely replace is about ninety lines (PRD 11.13). numpy is feasible inside Fusion and runs at full native speed, but costs 56 MB per platform pinned to the cp314 ABI to buy headroom this workload does not need (ADR-013). The other half of that decision is not optional: every routine here is differentially tested against numpy over randomized input (PRD 13.3 T14, tests/unit/test_linalg.py), and the numeric core runs with RuntimeWarning promoted to an error.

Units:

This module is unit-agnostic. Callers work in millimeters and radians (PRD 7.6); nothing here inspects or converts units.

Hazards:

eigen_sym3() carries the one real bug the numpy oracle has already caught — an overflow in the Jacobi rotation on a near-circular cross-section (PRD 11.13). See its docstring before changing it.

class mighty_miter.core.linalg.Mat3(row0, row1, row2)[source]

Bases: object

An immutable 3x3 matrix, stored as three row vectors.

Row-major throughout: row0 is the first row, so Mat3.apply computes M v with v treated as a column.

Variables:
Parameters:

Example

>>> Mat3.identity().apply(Vec3(1.0, 2.0, 3.0))
Vec3(x=1.0, y=2.0, z=3.0)
row0: Vec3
row1: Vec3
row2: Vec3
classmethod identity()[source]

Return the 3x3 identity matrix.

Returns:

I.

Return type:

Mat3

Example

>>> Mat3.identity().determinant()
1.0
classmethod zero()[source]

Return the 3x3 zero matrix.

Returns:

The all-zero matrix.

Return type:

Mat3

Example

>>> Mat3.zero().determinant()
0.0
classmethod from_rows(rows)[source]

Build a matrix from three row 3-tuples.

Parameters:

rows (tuple[tuple[float, float, float], ...]) – Exactly three (a, b, c) tuples, in row order.

Returns:

The matrix whose i-th row is rows[i].

Raises:

ValueError – If rows does not contain exactly three entries.

Return type:

Mat3

Example

>>> diagonal = Mat3.from_rows(((1.0, 0.0, 0.0), (0.0, 2.0, 0.0), (0.0, 0.0, 3.0)))
>>> diagonal.determinant()
6.0
classmethod from_columns(columns)[source]

Build a matrix whose columns are the given vectors.

This is the natural constructor for a frame or an eigenvector basis, where each column is a direction.

Parameters:

columns (tuple[Vec3, Vec3, Vec3]) – The three column vectors, in order.

Returns:

The matrix [c0 | c1 | c2].

Return type:

Mat3

Example

>>> basis = (Vec3(0.0, 1.0, 0.0), Vec3(-1.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0))
>>> Mat3.from_columns(basis).apply(Vec3(1.0, 0.0, 0.0))
Vec3(x=0.0, y=1.0, z=0.0)
rows()[source]

Return the three rows.

Returns:

(row0, row1, row2).

Return type:

tuple[Vec3, Vec3, Vec3]

Example

>>> Mat3.identity().rows()[1]
Vec3(x=0.0, y=1.0, z=0.0)
columns()[source]

Return the three columns.

Returns:

(col0, col1, col2).

Return type:

tuple[Vec3, Vec3, Vec3]

Example

>>> Mat3.identity().columns()[2]
Vec3(x=0.0, y=0.0, z=1.0)
at(i, j)[source]

Return the element in row i, column j.

Parameters:
  • i (int) – Row index, 0-based.

  • j (int) – Column index, 0-based.

Returns:

M[i][j].

Raises:

IndexError – If either index is outside 0..2.

Return type:

float

Example

>>> Mat3.identity().at(2, 2)
1.0
apply(vector)[source]

Return the matrix-vector product M v.

Parameters:

vector (Vec3) – The column vector to transform.

Returns:

M v.

Return type:

Vec3

Example

>>> Mat3.identity().apply(Vec3(1.0, 2.0, 3.0))
Vec3(x=1.0, y=2.0, z=3.0)
matmul(other)[source]

Return the matrix product self @ other.

Parameters:

other (Mat3) – The right-hand matrix.

Returns:

self @ other.

Return type:

Mat3

Example

>>> Mat3.identity().matmul(Mat3.identity()) == Mat3.identity()
True
transposed()[source]

Return the transpose.

Returns:

M^T.

Return type:

Mat3

Example

>>> upper = Mat3.from_rows(((1.0, 2.0, 3.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)))
>>> upper.transposed().row0
Vec3(x=1.0, y=0.0, z=0.0)
trace()[source]

Return the sum of the diagonal.

Returns:

M[0][0] + M[1][1] + M[2][2].

Return type:

float

Example

>>> Mat3.identity().trace()
3.0
determinant()[source]

Return the determinant.

\(\det M = r_0 \cdot (r_1 \times r_2)\) for rows \(r_i\).

In plain language: the signed volume of the box spanned by the rows. Negative means the matrix mirrors — a handedness hazard (PRD 7.1) wherever this matrix is used as a frame.

Returns:

det(M).

Return type:

float

Example

>>> scaling = Mat3.from_rows(((2.0, 0.0, 0.0), (0.0, 3.0, 0.0), (0.0, 0.0, 4.0)))
>>> scaling.determinant()
24.0
frobenius_norm()[source]

Return the Frobenius norm, the square root of the sum of squared entries.

Used to scale convergence thresholds and test tolerances, since the absolute error of an eigendecomposition scales with the size of the matrix.

Returns:

||M||_F.

Return type:

float

Example

>>> Mat3.identity().frobenius_norm() == math.sqrt(3.0)
True
is_symmetric(*, tolerance=1e-12)[source]

Report whether the matrix equals its transpose to a relative tolerance.

Parameters:

tolerance (float) – Relative tolerance. The comparison is |M[i][j] - M[j][i]| <= tolerance * max(1, ||M||_F). Default 1e-12, about 4500 times machine epsilon: loose enough for a matrix assembled as A^T A from real data, where the two triangles are computed by different summation orders, and tight enough that a genuinely asymmetric matrix is rejected.

Returns:

True if symmetric within the tolerance.

Return type:

bool

Example

>>> symmetric = Mat3.from_rows(((1.0, 2.0, 3.0), (2.0, 4.0, 5.0), (3.0, 5.0, 6.0)))
>>> symmetric.is_symmetric()
True
class mighty_miter.core.linalg.Vec3(x, y, z)[source]

Bases: object

An immutable 3-vector.

The core exchanges plain value objects across the adapter boundary rather than adsk handles (PRD 12.3, ADR-002), which is why this type exists even though numpy would otherwise supply it (PRD 11.13).

Variables:
  • x (float) – First component. Unit-agnostic; millimeters in geometric use (PRD 7.6).

  • y (float) – Second component.

  • z (float) – Third component.

Parameters:

Example

>>> Vec3(1.0, 2.0, 3.0) + Vec3(0.5, 0.5, 0.5)
Vec3(x=1.5, y=2.5, z=3.5)
x: float
y: float
z: float
classmethod zero()[source]

Return the zero vector.

Returns:

Vec3(0.0, 0.0, 0.0).

Return type:

Vec3

Example

>>> Vec3.zero()
Vec3(x=0.0, y=0.0, z=0.0)
classmethod from_tuple(values)[source]

Build a vector from a 3-tuple.

Parameters:

values (tuple[float, float, float]) – (x, y, z).

Returns:

The equivalent Vec3.

Return type:

Vec3

Example

>>> Vec3.from_tuple((1.0, 0.0, 0.0))
Vec3(x=1.0, y=0.0, z=0.0)
as_tuple()[source]

Return the components as a plain tuple.

Returns:

(x, y, z).

Return type:

tuple[float, float, float]

Example

>>> Vec3(1.0, 2.0, 3.0).as_tuple()
(1.0, 2.0, 3.0)
scaled(scalar)[source]

Return this vector multiplied by scalar.

Named alternative to v * scalar for call sites where the operator would be ambiguous next to a dot or cross product.

Parameters:

scalar (float) – The multiplier.

Returns:

scalar * self.

Return type:

Vec3

Example

>>> Vec3(1.0, 2.0, 3.0).scaled(2.0)
Vec3(x=2.0, y=4.0, z=6.0)
dot(other)[source]

Return the Euclidean inner product.

\(a \cdot b = a_x b_x + a_y b_y + a_z b_z\).

In plain language: how much of other points along self, times the lengths of both.

Parameters:

other (Vec3) – The second operand.

Returns:

The scalar product, in the square of the caller’s length unit.

Return type:

float

Example

>>> Vec3(1.0, 2.0, 3.0).dot(Vec3(4.0, -5.0, 6.0))
12.0
cross(other)[source]

Return the right-handed cross product.

\(a \times b = (a_y b_z - a_z b_y,\; a_z b_x - a_x b_z,\; a_x b_y - a_y b_x)\).

In plain language: a vector perpendicular to both inputs, whose length is the area of the parallelogram they span, pointing the way a right-handed screw turning self into other would advance.

Hazard:

Handedness (PRD 7.1). This is the right-handed convention and the template coordinate convention of PRD 4 depends on it. A mirrored template looks perfect and fits nothing.

Parameters:

other (Vec3) – The second operand.

Returns:

self x other.

Return type:

Vec3

Example

>>> Vec3(1.0, 0.0, 0.0).cross(Vec3(0.0, 1.0, 0.0))
Vec3(x=0.0, y=0.0, z=1.0)
norm_squared()[source]

Return the squared Euclidean length.

Prefer this to norm() ** 2 when comparing magnitudes: it avoids a square root and the rounding it introduces.

Returns:

self . self.

Return type:

float

Example

>>> Vec3(3.0, 4.0, 0.0).norm_squared()
25.0
norm()[source]

Return the Euclidean length.

Uses math.hypot(), which is correctly rounded and does not overflow for inputs whose squares would (a plain sqrt(x*x + y*y + z*z) overflows above about 1.3e154).

Returns:

The length, in the caller’s length unit.

Return type:

float

Example

>>> Vec3(3.0, 4.0, 0.0).norm()
5.0
normalized(*, tolerance=1e-12)[source]

Return a unit vector in the same direction.

Parameters:

tolerance (float) – Lengths at or below this are treated as zero and rejected. Default 1e-12, chosen because the core works in millimeters (PRD 7.6) and 1e-12 mm is a picometer — far below any length that can arise from CAD geometry, so a vector this short is a degenerate direction rather than a small one.

Returns:

self / |self|.

Raises:

ValueError – If |self| <= tolerance; a direction cannot be recovered from a zero-length vector and silently returning one would produce a plausible but wrong frame (PRD 7.10).

Return type:

Vec3

Example

>>> Vec3(0.0, 5.0, 0.0).normalized()
Vec3(x=0.0, y=1.0, z=0.0)
mighty_miter.core.linalg.eigen_sym3(matrix, *, symmetry_tolerance=1e-12)[source]

Return the eigenvalues and eigenvectors of a real symmetric 3x3 matrix.

Uses the cyclic Jacobi method: repeatedly apply a plane rotation that annihilates the largest remaining off-diagonal entry, until the off-diagonal mass is negligible. For a symmetric matrix the eigenvalues are real and the eigenvectors orthogonal, so the accumulated rotations are the eigenvector basis.

The rotation angle for the (p, q) entry comes from \(\theta = (a_{qq} - a_{pp}) / (2 a_{pq})\), with \(t = \operatorname{sign}(\theta) / (|\theta| + \sqrt{\theta^2 + 1})\).

In plain language: find the three perpendicular directions along which the matrix only stretches, and by how much.

Hazard:

Overflow in the Jacobi rotation (PRD 11.13). When the off-diagonal entry is tiny relative to the diagonal gap — exactly what a near-circular cross-section produces (PRD 11.10) — the textbook rotation formula overflows silently and the rotation collapses to the identity, so the sweep stops making progress. This routine is guarded in two independent places. The tangent comes from _jacobi_tangent, which takes a first-order branch before overflow is possible, and the annihilated entry is written to zero explicitly rather than left to the rotation, so even a lost rotation cannot stall the sweep. Do not remove either guard. The bug this describes was caught by the numpy oracle and by nothing else (PRD 13.3 T14); tests/unit/test_linalg.py holds the regression.

Parameters:
  • matrix (Mat3) – A real symmetric 3x3 matrix. Only the upper triangle is read; the lower triangle is checked against it and otherwise ignored. Unit-agnostic — for a second-moment matrix of points in millimeters the eigenvalues are in mm^2.

  • symmetry_tolerance (float) – Relative tolerance passed to Mat3.is_symmetric(). Default 1e-12; see that method for the justification.

Returns:

  • eigenvalues — three floats in descending order.

  • eigenvectors — three orthonormal Vec3, eigenvectors[i] belonging to eigenvalues[i].

The basis is right-handed: eigenvectors[2] == eigenvectors[0].cross( eigenvectors[1]) and the determinant of the three as columns is +1. This is the convention PRD 4 fixes, and it is guaranteed here rather than left to the caller because a mirrored frame is PRD 7.1’s first hazard — a mirrored template looks perfect and fits nothing, and the Jacobi accumulation on its own yields a left-handed basis for about half of all inputs.

The result is also deterministic: the signs of eigenvectors[0] and eigenvectors[1] are pinned so that each one’s largest-magnitude component is positive, and the third follows from the first two. An eigenvector is only defined up to sign, so pinning it costs nothing and it is what makes the export golden files byte-comparable (ADR-012, PRD 13.3 T10).

Note that both guarantees are about the basis, not about which eigenvector is which: the pairing with eigenvalues is exact either way.

Return type:

A pair (eigenvalues, eigenvectors)

Raises:
  • ValueError – If matrix is not symmetric within symmetry_tolerance, or if any entry is not finite. Both are programming errors upstream, and both would otherwise yield a confident wrong answer.

  • ArithmeticError – If the sweeps do not converge. This cannot happen for a finite symmetric 3x3 input and indicates a defect in this function.

Example

>>> diagonal = Mat3.from_rows(((3.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 2.0)))
>>> values, vectors = eigen_sym3(diagonal)
>>> values
(3.0, 2.0, 1.0)
>>> vectors[0]
Vec3(x=1.0, y=0.0, z=0.0)

References

Golub, G. H. and Van Loan, C. F., Matrix Computations, 4th ed., Johns Hopkins University Press, 2013, section 8.5 (the cyclic and threshold Jacobi methods). This is the reference for the method, the rotation formulas given above and the stable form of the tangent in _jacobi_tangent.

The “negligible after four sweeps” cutoff below is the threshold-Jacobi idea in the specific form popularized by Press, W. H. et al., Numerical Recipes, section 11.1, and is restated here from that description. No code from that book is reproduced or adapted — it is not redistributable, so this matters. The routine below is written for the 3x3 case directly: it accumulates no b/z correction vectors, has no tresh schedule, tests convergence against a Frobenius-scaled threshold, and enumerates the three index pairs rather than looping over n.

core.types

Plain value objects that cross the core/adapter boundary (PRD 12.2, 12.3).

Data crosses the boundary as dataclasses, tuples and floats — never as adsk handles (PRD 12.1, ADR-002). Everything here is frozen, so a value cannot be mutated behind a caller’s back and a template can be recomputed from its inputs.

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. A centimeter reaching any type in this module is a defect, not a rounding difference.

Template coordinates (PRD 4, fixed — do not vary):

x is arc length around the tube measured from the datum, increasing counter-clockwise when viewed along the tube axis in the direction of the outward-pointing end. y is axial distance, increasing away from the cut end. The origin is the datum position at the axial reference. TemplatePoint exists to make that convention a type rather than a comment, because getting the handedness wrong produces a template that looks correct and fits nothing (PRD 7.1).

mighty_miter.core.types.AnalyticSurface = mighty_miter.core.types.CylinderParameters | mighty_miter.core.types.ConeParameters | mighty_miter.core.types.TorusParameters

The analytic surface descriptions a face may carry.

Returned by analytic_parameters(), which reports None for a NURBS or otherwise non-analytic face. Making “this face has no closed form” an explicit, typed answer is the point: it is a fact a tier must branch on, not an omission it can overlook.

Match on it with isinstance(), and handle None — Tier E needs nothing from this union and works from :meth:` ~mighty_miter.core.protocols.SurfaceSource.first_derivatives` alone (PRD 6.0), so there is always a fallback.

class mighty_miter.core.types.ConeParameters(apex, axis, half_angle)[source]

Bases: object

The closed-form description of a conical face (PRD 6.3, Tier B).

Hazard:

Cone apex (PRD 6.3, CLAUDE.md section 6). Fusion’s Cone.origin is not the apex, and Cone.radius is the radius at the origin, not a defining radius of the cone. The apex is origin - (radius / tan(half_angle)) * axis. Getting this wrong yields a template with a plausible but wrong developed sweep angle — it looks right and it misfits.

This type therefore stores the apex, computed once at the adapter boundary, rather than the origin. Every downstream consumer then reads a value that cannot be confused with Fusion’s, and the trap is entered at most once in the whole program instead of once per consumer. Do not add an origin field: the point of the design is that the raw value has no route across the boundary.

Variables:
  • apex (mighty_miter.core.linalg.Vec3) – The cone’s apex point in millimeters, in the model’s world coordinates. Not Fusion’s Cone.origin; see the hazard above.

  • axis (mighty_miter.core.linalg.Vec3) – Unit vector along the axis, dimensionless, pointing from the apex into the body of the cone, so that a point at axial distance d from the apex has local radius d * tan(half_angle).

  • half_angle (float) – The half-angle at the apex, in radians, measured between the axis and a ruling. Strictly between 0 and pi/2; a zero half-angle is a cylinder and belongs in CylinderParameters.

Parameters:

Example

A cone whose apex is at the origin, opening along +z: at 100 mm along the axis its local radius is 100 * tan(0.1).

>>> import math
>>> cone = ConeParameters(Vec3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0), 0.1)
>>> round(100.0 * math.tan(cone.half_angle), 6)
10.033467
apex: Vec3
axis: Vec3
half_angle: float
class mighty_miter.core.types.CylinderParameters(origin, axis, radius)[source]

Bases: object

The closed-form description of a cylindrical face (PRD 6.2, Tier A).

PRD 6.2 writes Tier A as “given cylinder origin O, unit axis a, radius r…”, and instructs the implementer to prefer the geometric form because it is independent of Fusion’s parameterization and therefore portable (G6). This type is how those three values reach core/ without being re-derived by sampling the parameterization, which would defeat the reason the PRD asks for the geometric form.

Variables:
  • origin (mighty_miter.core.linalg.Vec3) – A point on the axis, in millimeters, in the model’s world coordinates. Any point on the axis is as good as any other; the adapter reports what Fusion gives it.

  • axis (mighty_miter.core.linalg.Vec3) – Unit vector along the axis, dimensionless. Direction is Fusion’s, not normalized against the cut end — a consumer that needs an outward-pointing axis must orient it itself (PRD 7.2).

  • radius (float) – The cylinder radius in millimeters. Always positive.

Parameters:

Example

>>> CylinderParameters(Vec3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0), 17.5).radius
17.5
origin: Vec3
axis: Vec3
radius: float
class mighty_miter.core.types.Metric(e, f, g)[source]

Bases: object

The first fundamental form (E, F, G) at one parameter (PRD 6.0).

Lengths on a parametric surface \(P(u, v)\) are governed entirely by

\[ds^2 = E\, du^2 + 2F\, du\, dv + G\, dv^2,\quad E = P_u \cdot P_u,\quad F = P_u \cdot P_v,\quad G = P_v \cdot P_v\]

In plain language: E and G say how much real distance one unit of each parameter buys, and F says how much the two parameter directions lean on each other. Every unrolling tier is a closed-form or numerical answer to “integrate this metric”, which is what makes the tiers mutually testable (PRD 6.0, ADR-004).

All three analytic surface types measured inside Fusion are orthogonal (F = 0); see PRD 6.0’s table. Do not assume it for a NURBS face.

Variables:
  • e (float) – P_u . P_u, in millimeters squared per unit of u squared.

  • f (float) – P_u . P_v, in millimeters squared per unit of u times unit of v.

  • g (float) – P_v . P_v, in millimeters squared per unit of v squared.

Parameters:

Example

>>> Metric.from_derivatives(Vec3(2.0, 0.0, 0.0), Vec3(0.0, 3.0, 0.0))
Metric(e=4.0, f=0.0, g=9.0)
e: float
f: float
g: float
classmethod from_derivatives(p_u, p_v)[source]

Build the metric from the two first derivatives.

This is the single place the metric is formed. getFirstDerivative yields (P_u, P_v) for any surface type including NURBS, which is why one abstraction serves all five tiers (PRD 6.0).

Parameters:
  • p_u (Vec3) – dP/du, in millimeters per unit of u.

  • p_v (Vec3) – dP/dv, in millimeters per unit of v.

Returns:

The metric at that parameter.

Return type:

Metric

Example

>>> Metric.from_derivatives(Vec3(1.0, 0.0, 0.0), Vec3(1.0, 1.0, 0.0))
Metric(e=1.0, f=1.0, g=2.0)
determinant()[source]

Return \(EG - F^2\), the squared area element.

In plain language: the square of the area one unit of u by one unit of v covers on the surface. It is zero exactly where the parameterization degenerates — at a cone apex, or at a pole — and negative only through round-off.

Returns:

e * g - f * f, in millimeters to the fourth power.

Return type:

float

Example

>>> Metric(4.0, 0.0, 9.0).determinant()
36.0
is_degenerate(*, tolerance=1e-18)[source]

Report whether the parameterization has collapsed at this point.

Parameters:

tolerance (float) – Threshold on determinant(), in mm^4. Default 1e-18, which is (1e-6 mm^2)^2: an area element below a square nanometer per unit parameter squared is a collapsed parameterization, not a small one. A real cone apex or a pole reaches exactly zero.

Returns:

True when the area element is at or below the tolerance.

Return type:

bool

Example

>>> Metric(1.0, 1.0, 1.0).is_degenerate()
True
is_orthogonal(*, tolerance=1e-12)[source]

Report whether F vanishes relative to the size of the metric.

Parameters:

tolerance (float) – Relative tolerance. The test is |f| <= tolerance * sqrt(e * g), comparing F against the geometric mean of the two diagonal terms, which is the only scale F can be compared against without units cancelling wrongly. Default 1e-12, about 4500 times machine epsilon: loose enough for a metric assembled from two derivative evaluations, tight enough to reject a genuinely skewed parameterization, whose F is a percent or more of sqrt(EG).

Returns:

True when the parameter directions are perpendicular to that tolerance.

Return type:

bool

Example

>>> Metric(4.0, 0.0, 9.0).is_orthogonal()
True
>>> Metric(4.0, 1.0, 9.0).is_orthogonal()
False
arc_length_element(du, dv)[source]

Return the surface distance covered by a parametric step.

\(ds = \sqrt{E\,du^2 + 2F\,du\,dv + G\,dv^2}\).

In plain language: how far you actually move on the surface when you nudge the parameters by (du, dv).

Parameters:
  • du (float) – Step in the first parameter.

  • dv (float) – Step in the second parameter.

Returns:

The distance in millimeters — a first-order quantity, being the length of the tangent step rather than of the curve between the two points. The two differ at second order in the step size.

Raises:

ValueError – If the quadratic form evaluates negative, which can only happen for a metric that is not positive semi-definite — i.e. one that was not built from real derivatives. Returning nan instead would propagate silently (FR-10.2).

Return type:

float

Example

>>> Metric(4.0, 0.0, 9.0).arc_length_element(1.0, 0.0)
2.0
class mighty_miter.core.types.Polyline3D(points, closed=False)[source]

Bases: object

An ordered polyline in model space, in millimeters.

This is what a miter curve looks like once it has crossed the adapter boundary: the ordered 3D points of the cut edge chain (PRD 4). The adapter produces it from CurveEvaluator3D.getStrokes, whose tolerance is in centimeters — a PRD 7.6 hazard handled once, in adapter/units.py.

Variables:
  • points (tuple[mighty_miter.core.linalg.Vec3, ...]) – The ordered points, in millimeters. At least two.

  • closed (bool) – True when the polyline forms a loop. A miter curve is closed for a full cope and open for a partial one; both are in scope (PRD 13.2). The flag is carried explicitly rather than inferred from coincident endpoints, because a closed curve’s endpoints only coincide to the stroking tolerance.

Raises:

ValueError – If fewer than two points are supplied.

Parameters:

Example

>>> line = Polyline3D((Vec3(0.0, 0.0, 0.0), Vec3(3.0, 4.0, 0.0)))
>>> line.length()
5.0
points: tuple[Vec3, ...]
closed: bool
segments()[source]

Return the ordered segments, including the closing one when closed.

Returns:

Pairs of consecutive points.

Return type:

tuple[tuple[Vec3, Vec3], …]

length()[source]

Return the total polyline length in millimeters.

Returns:

The sum of the segment lengths. For a stroked curve this underestimates the true arc length by the chordal deviation used to stroke it, which is the tolerance the caller passed to stroke_points (PRD 7.6).

Return type:

float

Example

>>> Polyline3D((Vec3(0.0, 0.0, 0.0), Vec3(1.0, 0.0, 0.0))).length()
1.0
class mighty_miter.core.types.TemplatePoint(x, y)[source]

Bases: object

A point in template coordinates, in millimeters (PRD 4).

Hazard:

Handedness (PRD 7.1). x increases counter-clockwise when viewed along the tube axis in the direction of the outward-pointing end. This convention is fixed in PRD 4, must be asserted in code, and must be covered by the physical validation gate (PRD 13.3 T6). A mirrored template looks perfect and fits nothing.

Variables:
  • x (float) – Arc length around the tube from the datum, in millimeters, increasing counter-clockwise as described above.

  • y (float) – Axial distance in millimeters, increasing away from the cut end.

Parameters:

Example

>>> TemplatePoint(12.5, -3.0)
TemplatePoint(x=12.5, y=-3.0)
x: float
y: float
class mighty_miter.core.types.TemplatePolyline(points, closed=False)[source]

Bases: object

An ordered polyline in template coordinates, in millimeters.

This is the output of an unrolling tier: the miter curve mapped into the plane, ready to be drawn (PRD 6.0).

Variables:
  • points (tuple[mighty_miter.core.types.TemplatePoint, ...]) – The ordered points in template coordinates. At least two.

  • closed (bool) – True when the path closes in the template plane — that is, when the last point joins back to the first, and length() should count that segment. This is not a “wraps the full circumference” flag: an unrolled full wrap is open in the plane, its two ends lying one circumference apart in x, so unroll_cylinder() returns closed=False even for a closed 3D curve. Marking such a wrap closed would add a phantom segment right across the template.

Raises:

ValueError – If fewer than two points are supplied.

Parameters:

Example

>>> wrap = TemplatePolyline((TemplatePoint(0.0, 0.0), TemplatePoint(3.0, 4.0)))
>>> wrap.length()
5.0
points: tuple[TemplatePoint, ...]
closed: bool
length()[source]

Return the total length in millimeters, measured in the template plane.

Returns:

The sum of the segment lengths, including the closing segment when closed.

Return type:

float

Example

>>> TemplatePolyline((TemplatePoint(0.0, 0.0), TemplatePoint(0.0, 2.0))).length()
2.0
bounds()[source]

Return the axis-aligned bounding box, in millimeters.

Returns:

(x_min, x_max, y_min, y_max). Used to lay a template onto a sheet and to size the calibration geometry (PRD 8.2).

Return type:

tuple[float, float, float, float]

Example

>>> wrap = TemplatePolyline((TemplatePoint(-1.0, 0.0), TemplatePoint(3.0, 4.0)))
>>> wrap.bounds()
(-1.0, 3.0, 0.0, 4.0)
class mighty_miter.core.types.TorusParameters(center, axis, major_radius, minor_radius)[source]

Bases: object

The closed-form description of a toroidal face (PRD 6.5, Tier D).

A torus is the constant-radius special case of the channel surface Tier D handles, and it is the one case where the r/R strain bound PRD 6.5 quotes is exact rather than estimated.

Hazard:

Analytic bound quoted for a face that does not qualify (PRD 6.5, PRD 7.12). The presence of this type says the face is a torus, so minor_radius really is constant along it. That is exactly the constant-radius gate PRD 6.5 makes mandatory before an r/R bound may be claimed. A face that merely looks toroidal — a real bend runs from 0.03 % to 60.8 % ovality along one face — must report None from analytic_parameters() and be handled by Tier D or E on measured geometry instead.

Variables:
  • center (mighty_miter.core.linalg.Vec3) – The center of the major circle, in millimeters, in world coordinates.

  • axis (mighty_miter.core.linalg.Vec3) – Unit vector normal to the plane of the major circle, dimensionless.

  • major_radius (float) – Distance from center to the tube’s spine, in millimeters (R). The bend radius.

  • minor_radius (float) – Radius of the tube section, in millimeters (r).

Parameters:

Example

>>> torus = TorusParameters(Vec3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0), 210.0, 12.5)
>>> round(torus.minor_radius / torus.major_radius, 6)
0.059524
center: Vec3
axis: Vec3
major_radius: float
minor_radius: float

core.protocols

The contract between core/ and adapter/ (PRD 12.3).

The core declares what it needs as 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 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 SurfaceSource.first_derivatives() yields the first fundamental form directly (PRD 6.0), which is why the tiers can share one input.

class mighty_miter.core.protocols.CurveSource(*args, **kwargs)[source]

Bases: 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/.

parametric_range()[source]

Return the parameter domain of the curve.

Returns:

(t_min, t_max) in the curve’s own parameter.

Return type:

tuple[float, float]

point_at(t)[source]

Return the curve point at a parameter.

Parameters:

t (float) – Curve parameter, within parametric_range().

Returns:

The point in millimeters, in the model’s world coordinates.

Return type:

Vec3

first_derivative(t)[source]

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.

Parameters:

t (float) – Curve parameter, within parametric_range().

Returns:

dP/dt in millimeters per unit parameter.

Return type:

Vec3

length_between(t_start, t_end)[source]

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 length() on the output of 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.

Parameters:
Returns:

the result is the length of the span, not a signed displacement, so swapping the arguments returns the same number.

Return type:

The arc length in millimeters. Always non-negative

is_closed()[source]

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.

Return type:

bool

stroke_points(tolerance_mm)[source]

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.

Parameters:

tolerance_mm (float) – Maximum chordal deviation of the polyline from the true curve, in millimeters.

Returns:

The ordered points, in millimeters, first point first.

Return type:

tuple[Vec3, …]

class mighty_miter.core.protocols.SurfaceSource(*args, **kwargs)[source]

Bases: 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 isinstance() against a runtime-checkable protocol checks only that the attribute names exist, never their signatures.

parametric_range()[source]

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 is_on_face() to test membership.

Return type:

tuple[float, float, float, float]

point_at(u, v)[source]

Return the surface point at a parameter.

Parameters:
  • u (float) – First surface parameter.

  • v (float) – Second surface parameter.

Returns:

The point P(u, v) in millimeters, in the model’s world coordinates.

Return type:

Vec3

first_derivatives(u, v)[source]

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 \(E = P_u \cdot P_u\), \(F = P_u \cdot P_v\), \(G = P_v \cdot P_v\), for any surface type including NURBS. Build the result with 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.

Parameters:
  • u (float) – First surface parameter.

  • v (float) – Second surface parameter.

Returns:

(P_u, P_v), in millimeters per unit of the respective parameter.

Return type:

tuple[Vec3, Vec3]

curvatures(u, v)[source]

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

Parameters:
  • u (float) – First surface parameter.

  • v (float) – 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).

Return type:

tuple[float, float]

is_on_face(u, v)[source]

Report whether a parameter lies inside the trimmed face.

Parameters:
  • u (float) – First surface parameter.

  • v (float) – 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.

Return type:

bool

outward_normal_at(u, v)[source]

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.

Parameters:
  • u (float) – First surface parameter.

  • v (float) – Second surface parameter.

Returns:

A unit vector, dimensionless, guaranteed to point away from the tube axis.

Return type:

Vec3

analytic_parameters()[source]

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

Return type:

CylinderParameters | ConeParameters | TorusParameters | None

core.errors

The MiterTemplateError hierarchy (PRD 12.5, FR-10).

Every failure this add-in reports carries three things, because FR-10.1 requires an error to name the offending entity and say what to do about it:

code

A stable, machine-readable identifier such as MM-SELECTION. Codes are per class, not per reason: every SelectionError reports MM-SELECTION whatever went wrong with the selection, and the specific reason is carried by entity_name and remediation instead. Introducing a finer code means introducing a subclass to own it. Codes are part of the public API (PRD 16.1): renaming one is a breaking change, because a log analyzer or a support answer may refer to it.

entity_name

The user-visible name of the offending entity — the name shown in Fusion’s browser, not an entityToken and not an internal index. “Edge 3” tells the user nothing; “the edge between Tube1 and Tube2” tells them where to look.

remediation

One sentence saying what to do. FR-10.1’s own example is the standard to meet: “Edges 3 and 5 are not connected to the chain — select the edges between them, or template them separately.”

Layering (PRD 12.5):

The hierarchy lives in core/ so the geometry layer can raise it without knowing Fusion exists. adapter/ translates Fusion exceptions into it. ui/ is the only layer that shows a message box; nothing in core/ or adapter/ may print, log to the console, or pop a dialog.

FR-10.2 — never fail silently:

An exception from this hierarchy is the mechanism for refusing to produce output. A wrong template is worse than no template, because a fabricator cuts metal to it (CLAUDE.md section 10). Where a computation cannot be trusted, raise; do not return a plausible number.

exception mighty_miter.core.errors.AdapterError(message, *, entity_name=None, remediation)[source]

Bases: MiterTemplateError

A Fusion API call failed or returned a shape the adapter does not accept.

Defined in core/ so the hierarchy has one root, but raised only by adapter/. This is the translation layer PRD 12.5 requires: a raw RuntimeError from the Fusion API carries no entity name and no remediation, and must not reach the user.

Note

The shipped type stubs are not authoritative (PRD 7.11). An API that returns an unexpected shape is a Fusion-version problem, not a user problem, so the remediation should say which Fusion version was verified and point at the log (FR-10.3).

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-ADAPTER'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.AmbiguousHostFaceError(message, *, entity_name=None, remediation)[source]

Bases: HostFaceError

Two or more faces are equally plausible hosts, so the user must choose (FR-2).

Hazard:

Two candidate host faces from one cut (PRD 7.13). A severing boolean yields two faces of identical radius and near-identical area. Never auto-pick “the largest” — the choice decides which side of the cut the template describes, and picking wrong produces a template that is perfect for the other piece.

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-HOST-FACE-AMBIGUOUS'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.AnalyticBoundUnavailableError(message, *, entity_name=None, remediation)[source]

Bases: MiterTemplateError

An analytic strain bound was requested for a face that does not qualify.

Hazard:

Analytic bound quoted for a face that does not qualify (PRD 6.5, PRD 7.12). The Tier D r/R bound is only valid for a genuine channel surface of constant section radius; the constant-radius gate is mandatory before quoting it. A confident wrong number is worse than an honest measured one, and a real face in the reference model runs from 0.03 % to 60.8 % ovality along its length, so there is often no single r to put in the formula at all.

Raising this is the correct outcome: the caller falls back to a measured strain figure and labels it as such (FR-5.1a).

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-STRAIN-BOUND-UNAVAILABLE'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.ClassificationError(message, *, entity_name=None, remediation)[source]

Bases: MiterTemplateError

The host face could not be assigned to an unrolling tier (PRD 6.1, ADR-005).

Classification is a statement about the measured metric, never about the type name the CAD kernel reports. A face whose metric is inconsistent across the template extent belongs here rather than being forced into a tier.

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-CLASSIFY'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.DatumError(message, *, entity_name=None, remediation)[source]

Bases: MiterTemplateError

The datum could not be established or is degenerate (FR-3, PRD 6.7).

Hazard:

Handedness (PRD 7.1). The datum fixes circumferential zero and, with it, the direction in which template x increases (PRD 4). A datum resolved from a degenerate reference silently mirrors the template, which looks perfect and fits nothing.

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-DATUM'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.ExportError(message, *, entity_name=None, remediation)[source]

Bases: MiterTemplateError

A template file could not be written (FR-8, PRD 8).

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-EXPORT'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.HostFaceError(message, *, entity_name=None, remediation)[source]

Bases: MiterTemplateError

The host face could not be resolved from the selection (FR-2).

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-HOST-FACE'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.MiterTemplateError(message, *, entity_name=None, remediation)[source]

Bases: Exception

Base class for every error this add-in raises deliberately.

Catching MiterTemplateError catches everything the add-in reports on purpose and nothing it does not; an escaping Exception of any other type is a defect and is handled by the top-level handler of FR-10.3, which writes a traceback to the rotating log.

Variables:
  • code (ClassVar[str]) – Stable machine-readable identifier. Subclasses set this; instances do not change it.

  • entity_name – User-visible name of the offending entity, or None when the failure is not attributable to one entity.

  • remediation – One sentence telling the user what to do (FR-10.1).

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

Example

>>> error = SelectionError(
...     "edges 3 and 5 are not connected to the chain",
...     entity_name="Tube1 miter edges",
...     remediation="Select the edges between them, or template them separately.",
... )
>>> error.code
'MM-SELECTION'
>>> print(error)
[MM-SELECTION] edges 3 and 5 are not connected to the chain
Entity: Tube1 miter edges
What to do: Select the edges between them, or template them separately.
code: ClassVar[str] = 'MM-ERROR'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.PersistenceError(message, *, entity_name=None, remediation)[source]

Bases: MiterTemplateError

A stored entity reference could not be re-resolved (FR-7.5, PRD 12.6).

The token failed, or it resolved to an entity whose geometric fingerprint has drifted beyond tolerance. Per PRD 12.6 the user is asked to confirm a fingerprint match rather than having one silently accepted.

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-PERSISTENCE'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.SelectionError(message, *, entity_name=None, remediation)[source]

Bases: MiterTemplateError

The selected edges cannot be turned into a single miter curve (FR-1).

Raised for a broken edge chain, a selection spanning two faces, or a selection with no edges. FR-10.1’s worked example is exactly this case.

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-SELECTION'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

exception mighty_miter.core.errors.UnrollError(message, *, entity_name=None, remediation)[source]

Bases: MiterTemplateError

The unrolling failed or did not converge (PRD 6.2 to 6.6).

Parameters:
  • message (str)

  • entity_name (str | None)

  • remediation (str)

Return type:

None

code: ClassVar[str] = 'MM-UNROLL'

Stable machine-readable identifier for this class of failure. Part of the public API (PRD 16.1) — renaming one is a breaking change.

core.datum

The datum frame: circumferential and axial zero. Derived from an explicit geometric direction and never from a reported parametric window (ADR-015, PRD 7.14).

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 CIRCUMFERENCE_SIGN, and asserted by tests/unit/test_datum.py::test_counter_clockwise_from_the_datum_is_positive_x.

mighty_miter.core.datum.CIRCUMFERENCE_SIGN = -1.0

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.

mighty_miter.core.datum.MIN_AXIAL_SPREAD_MM = 0.01

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

class mighty_miter.core.datum.CylinderDatum(origin, axis, reference, radius)[source]

Bases: object

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 circumferential_angle() (scaled by the radius to give x) and axial_offset() (which gives y directly); the tiers in mighty_miter.core.unroll do that pairing.

Construct through from_reference_direction() rather than calling the constructor, so the reference is orthogonalized against the axis and the invariants are checked once.

Variables:
  • origin (mighty_miter.core.linalg.Vec3) – A point on the cylinder axis, in millimeters, in world coordinates. Any axis point serves; it fixes only where axial zero sits, and with_axial_origin() moves it.

  • axis (mighty_miter.core.linalg.Vec3) – Unit vector along the axis, dimensionless, pointing away from the cut end so that template y increases away from the cut (PRD 4).

  • reference (mighty_miter.core.linalg.Vec3) – Unit vector perpendicular to axis, dimensionless, pointing at circumferential zero. This is the datum line on the tube.

  • radius (float) – Cylinder radius in millimeters. Strictly positive.

Parameters:
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
ORTHONORMAL_TOLERANCE = 1e-09

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.

classmethod from_reference_direction(cylinder, *, outward_axis, reference)[source]

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”).

Parameters:
  • cylinder (CylinderParameters) – The host cylinder, radius in millimeters.

  • outward_axis (Vec3) – 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 (Vec3) – 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.

Return type:

CylinderDatum

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)
binormal()[source]

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 CIRCUMFERENCE_SIGN, is at negative template x.

Return type:

Vec3

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)
with_axial_origin(origin)[source]

Return the same frame with axial zero moved to origin.

Parameters:

origin (Vec3) – 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.

Return type:

CylinderDatum

Note

The point is projected back onto the axis before it is stored, which is what makes the promise above true. origin is documented as a point on the axis, and 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)
point_at(x_mm, y_mm)[source]

Return the point on the cylinder at template coordinates (x, y).

The inverse of the Tier A unrolling: where unroll_cylinder() flattens the tube, this wraps the paper back onto it. With \(\varphi = x / r\) and \(\theta = \sigma\varphi\) for CIRCUMFERENCE_SIGN \(\sigma\),

\[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: CIRCUMFERENCE_SIGN appears here and in circumferential_angle(), and nowhere else.

Parameters:
  • x_mm (float) – Arc length from the datum, in millimeters, positive counter-clockwise as seen looking along the outward axis (PRD 4).

  • y_mm (float) – 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.

Return type:

Vec3

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
circumferential_angle(point)[source]

Return the signed angle of point about the axis, in radians.

This is the raw, wrapped angle in \((-\pi, +\pi]\), already carrying CIRCUMFERENCE_SIGN so that increasing angle means increasing template x. Along an ordered curve, pass the sequence through 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 CIRCUMFERENCE_SIGN:

\[\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.

Parameters:

point (Vec3) – 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 \((-\pi, +\pi]\).

Return type:

float

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
axial_offset(point)[source]

Return the signed distance of point along the axis from origin.

Parameters:

point (Vec3) – A point in world coordinates, in millimeters.

Returns:

The offset in millimeters, positive away from the cut end (PRD 4).

Return type:

float

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
mighty_miter.core.datum.default_datum(cylinder, curve, *, outward_axis)[source]

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.

Parameters:
  • cylinder (CylinderParameters) – The host cylinder, in millimeters.

  • curve (Polyline3D) – The miter curve in world coordinates, in millimeters, ordered. Only its points are read; closed is irrelevant to an extremum.

  • outward_axis (Vec3) – 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 MIN_AXIAL_SPREAD_MM.

Return type:

CylinderDatum

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)
mighty_miter.core.datum.unwrap_angles(angles)[source]

Remove \(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 \(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.

Parameters:

angles (tuple[float, ...]) – Angles in radians, in curve order. May be empty.

Returns:

The unwrapped angles, same length and same first element.

Return type:

tuple[float, …]

Note

A genuine step of more than \(\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(())
()

core.unroll

The unrolling tiers (PRD 6.0). Each maps a curve on a host face into template coordinates; which tier applies is decided by numerical classification, not by what the user says the shape is.

Unrolling tiers A-E — five closed-form solutions plus one numerical fallback.

All tiers answer the same question and share one input, the first fundamental form (E, F, G), and one output, a planar map (PRD 6.0). That shared shape is what makes them mutually testable: Tier E must reproduce Tiers A-D wherever those apply (PRD 13.3 T3).

Implemented so far: cylinder (Tier A, exact, M2). The remaining tier modules named in PRD 12.2 (cone, generalized, channel, general) are added by the milestones that implement them (PRD 17: M5, M6, M7).

core.unroll.cylinder

Tier A — the exact cylinder unroller (PRD 6.2).

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 \(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 CylinderDatum as an input and applies it inside the map. The intent PRD 6.7 is protecting is met, and more strongly: 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 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 mighty_miter.core.datum, via 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.

mighty_miter.core.unroll.cylinder.MAX_RADIAL_DEVIATION_MM = 0.01

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.

mighty_miter.core.unroll.cylinder.canonical_wrap(points, *, circumference, closed=False)[source]

Place an unrolled run in the canonical template window (PRD 4, FR-6.5).

Unwrapping (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 \(-450^\circ\) to \(-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.

Parameters:
  • points (tuple[TemplatePoint, ...]) – The unrolled run in template coordinates, in millimeters, in curve order.

  • circumference (float) – One full wrap, \(2 \pi r\), in millimeters. Must be positive.

  • closed (bool) – 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.

Return type:

tuple[TemplatePoint, …]

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]
mighty_miter.core.unroll.cylinder.unroll_cylinder(curve, datum, *, check_on_surface=True)[source]

Map an ordered 3D curve on a cylinder into template coordinates (PRD 6.2).

For each point \(P\), with axis \(a\), origin \(O\), radius \(r\) and datum directions \(u_0\), \(v_0 = a \times u_0\):

\[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 \((r\varphi,\; z)\), with \(\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 \(\varphi\) is CIRCUMFERENCE_SIGN and is the PRD 4 handedness convention; PRD 6.2 v1 omitted it. See that constant.

Parameters:
  • curve (Polyline3D) – The miter curve, ordered, in world coordinates, in millimeters. Its points are expected to lie on the cylinder to within MAX_RADIAL_DEVIATION_MM.

  • datum (CylinderDatum) – The frame fixing circumferential and axial zero, carrying the radius.

  • check_on_surface (bool) – 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 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 MAX_RADIAL_DEVIATION_MM from the cylinder of the given radius.

Return type:

TemplatePolyline

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

core.export

Self-authored output files. The add-in never calls a Fusion export API, because those are licence-gated and that gate has already moved once (PRD 8.1, ADR-013).

Self-authored PDF, DXF and SVG writers (PRD 8, ADR-003).

The add-in authors its own files rather than calling Fusion’s exporters, because a template must be dimensionally exact at 1:1 and carry its own calibration geometry (PRD 8.1, FR-8.1). Hand-writing is defensible only because an independent oracle exists for each format: pypdf plus a rendering check for PDF, the ezdxf auditor for DXF (PRD 13.3 T14, ADR-013). Those libraries are dev-only and never ship.

This package is intentionally empty at milestone M1; the writers land with M2 (PDF) and M9 (DXF, SVG) per PRD 17.

core.export.pdf

A minimal, dependency-free PDF 1.4 writer (PRD 8.2), differentially tested against pypdf (PRD 13.3 T14).

A minimal, dependency-free PDF writer for 1:1 printable templates (PRD 8.2).

Why this exists at all:

Fusion’s PDF export is licence-gated, and that gate has already moved once (PRD 8.1). The add-in therefore authors its own file. A vector-only PDF with a single content stream of path operators is a few hundred lines, so this is cheaper than owning a vendored dependency across three platforms — and it removes the licence question entirely rather than betting on it (ADR-013).

The other half of that bargain is not optional: a hand-written format must be differentially tested against an independent implementation (CLAUDE.md section 8, T14). tests/unit/test_pdf_export.py parses every output with pypdf and checks the page geometry it reports, so a malformed file fails CI rather than a printer.

Units:

The public API takes millimeters with the origin at the bottom-left of the page, which is PDF’s own orientation and avoids a flip nobody would remember. Internally everything converts once, through MM_TO_POINTS, into PDF user space of 1/72 inch (PRD 7.6 — the conversion happens in one place here, as it does at the Fusion boundary).

Determinism (ADR-012, T10):

Output is byte-identical for identical input. There are no timestamps, no object ordering that depends on dict iteration, and no floating-point text that varies with platform: every number goes through the module’s _format_number, which emits a fixed decimal form. created is the single variable field, is optional, and is omitted entirely unless the caller passes one — so the default output is stable and the golden file needs no masking.

Scope:

Straight-line paths, rectangles and base-14 Helvetica text. Curves are dense polylines rather than Béziers, which PRD 8.2 chooses deliberately: simpler, larger, and equally correct at print resolution. No font embedding, no images, no transparency, no compression — a template is a few thousand line segments and the file stays small.

mighty_miter.core.export.pdf.MM_TO_POINTS = 2.834645669291339

PDF user space is 1/72 inch, so one millimeter is 72 / 25.4 units. This is the only place the conversion appears (PRD 7.6).

mighty_miter.core.export.pdf.PAGE_SIZES_MM: dict[str, tuple[float, float]] = {'A3': (297.0, 420.0), 'A4': (210.0, 297.0), 'LETTER': (215.9, 279.4)}

Named page sizes in millimeters, (width, height) portrait.

A4 is 210 x 297 mm exactly; US Letter is 8.5 x 11 inches, which is 215.9 x 279.4 mm exactly, not a rounded figure. PRD 11.x measured the A4 MediaBox as exact against pypdf, so these values are the ones that were checked.

class mighty_miter.core.export.pdf.PdfCanvas(width_mm, height_mm, _operations=<factory>)[source]

Bases: object

Accumulates drawing operations and renders a single-page PDF (PRD 8.2).

Coordinates are in millimeters with the origin at the bottom-left of the page. Nothing is drawn until to_bytes() is called, and the canvas may be rendered more than once — rendering does not consume it.

Variables:
  • width_mm (float) – Page width in millimeters.

  • height_mm (float) – Page height in millimeters.

Raises:

ExportError – If either page dimension is not strictly positive.

Parameters:

Example

>>> canvas = PdfCanvas(210.0, 297.0)
>>> canvas.polyline([(10.0, 10.0), (100.0, 10.0)], width_mm=0.35)
>>> canvas.to_bytes(title="demo").startswith(b"%PDF-1.4")
True
width_mm: float
height_mm: float
classmethod of_size(name, *, landscape=False)[source]

Build a canvas for a named page size.

Parameters:
  • name (str) – A key of PAGE_SIZES_MM, case-insensitive.

  • landscape (bool) – Swap width and height.

Returns:

A new, empty canvas.

Raises:

ExportError – If the name is not a known page size.

Return type:

PdfCanvas

Example

>>> PdfCanvas.of_size("a4").width_mm
210.0
>>> PdfCanvas.of_size("a4", landscape=True).width_mm
297.0
polyline(points, *, width_mm, dash_mm=None)[source]

Stroke an open polyline.

Parameters:
  • points (list[tuple[float, float]] | tuple[tuple[float, float], ...]) – At least two (x, y) pairs in millimeters.

  • width_mm (float) – Stroke width in millimeters. PRD 8.2 fixes the weights: miter curve 0.35, datum 0.5, ticks 0.18, dashed overlap 0.25.

  • dash_mm (tuple[float, float] | None) – (on, off) dash pattern in millimeters, or None for solid.

Raises:

ExportError – If fewer than two points are given, or a coordinate is non-finite.

Return type:

None

rectangle(x_mm, y_mm, width_mm_size, height_mm_size, *, width_mm)[source]

Stroke an axis-aligned rectangle.

Used for the calibration rectangle (FR-6.6), which is what makes 1:1 verifiable rather than merely intended — printer drivers scale silently (PRD 8.4).

Parameters:
  • x_mm (float) – Left edge in millimeters.

  • y_mm (float) – Bottom edge in millimeters.

  • width_mm_size (float) – Rectangle width in millimeters.

  • height_mm_size (float) – Rectangle height in millimeters.

  • width_mm (float) – Stroke width in millimeters.

Raises:

ExportError – If the rectangle has non-positive extent.

Return type:

None

text(x_mm, y_mm, content, *, size_pt)[source]

Draw a single line of Helvetica text.

Parameters:
  • x_mm (float) – Left edge of the baseline, in millimeters.

  • y_mm (float) – Baseline height, in millimeters.

  • content (str) – Text in the WinAnsi (CP1252) range – ASCII plus Latin-1 and the CP1252 additions. Newlines are not interpreted; call once per line.

  • size_pt (float) – Font size in points, not millimeters — type is specified in points everywhere else in the world, and converting would surprise.

Raises:

ExportError – If the text contains a character CP1252 cannot encode, is empty, or the size is not positive.

Return type:

None

to_bytes(*, title, created=None)[source]

Render the canvas to a complete PDF 1.4 file.

Parameters:
  • title (str) – Document title, stored in the Info dictionary. WinAnsi (CP1252) range; see the _escape_text notes in this module.

  • created (str | None) – Optional PDF date string, e.g. "D:20260905T120000Z". The only variable field in the output (ADR-012). Omitted entirely when None, which is the default and is what makes the output byte-deterministic without masking.

Returns:

The complete file.

Raises:

ExportError – If nothing has been drawn, or the title contains a character CP1252 cannot encode.

Return type:

bytes

Note

The page carries no scaling metadata. PRD 8.2 requires this: any hint a viewer could interpret as “fit to page” defeats 1:1, so the instruction to disable scaling is printed on the template itself instead (PRD 8.4).

Example

>>> canvas = PdfCanvas(210.0, 297.0)
>>> canvas.rectangle(10.0, 10.0, 100.0, 50.0, width_mm=0.35)
>>> pdf = canvas.to_bytes(title="calibration")
>>> pdf.startswith(b"%PDF-1.4") and pdf.rstrip().endswith(b"%%EOF")
True
>>> canvas.to_bytes(title="calibration") == pdf  # deterministic
True

core.template

Assembles an unrolled miter curve onto a printable 1:1 sheet, with the calibration geometry that makes the print verifiable before metal is cut (FR-6, PRD 8.4).

Assemble an unrolled miter curve into a printable 1:1 template (PRD 6, FR-6).

This is the last step before paper: it takes a TemplatePolyline in template coordinates and lays it onto a page together with the marks that make the print verifiable rather than merely intended.

Why the verification furniture is not optional (PRD 8.4):

Printer drivers scale silently. A template that is 2 % small looks entirely correct and produces a tube that does not fit, and the fabricator has no way to notice before cutting. So every sheet carries a calibration rectangle of known size (FR-6.6) and a circumference check line with its true length printed on it (FR-6.7), and says in plain words that they must be measured first. Those marks are the difference between a 1:1 claim and a 1:1 guarantee.

Units and frames:

Template coordinates are millimeters, x circumferential from the datum and y axial away from the cut end (PRD 4). Page coordinates are millimeters from the bottom-left of the sheet. The two are related by one translation, computed in TemplateLayout — there is no scaling anywhere, by design: this is a 1:1 document and a scale factor is the one thing that must never enter it.

Scope at M2:

Cylinder-only, single edge, default datum (PRD 17). Implemented here: FR-6.1 miter curve, FR-6.2 outline, FR-6.3 datum mark, FR-6.4 circumferential ticks, FR-6.5 wrap overlap tab, FR-6.6 calibration rectangle, FR-6.7 circumference check line, FR-6.8 orientation warning and fiducial, and a reduced FR-6.9 annotation block. The full annotation block, the station-and-offset table (FR-6.10) and relief slits (FR-6.11) arrive with M9 and M7 respectively.

mighty_miter.core.template.CALIBRATION_RECTANGLE_MM = (100.0, 50.0)

Calibration rectangle size in millimeters (FR-6.6), (width, height).

100 x 50 mm is PRD 8.2’s own example and is chosen to be measurable with an ordinary steel rule: large enough that a 1 % scale error shows up as a full millimeter, and asymmetric so that a 90-degree rotation is obvious.

mighty_miter.core.template.LINE_WEIGHTS_MM = {'calibration': 0.35, 'datum': 0.5, 'miter': 0.35, 'outline': 0.18, 'overlap': 0.25, 'tick_major': 0.18, 'tick_minor': 0.18}

Stroke weights in millimeters, fixed by PRD 8.2. They are a specification, not a preference: the miter curve must be the most prominent line on the sheet because it is the one being cut to, and the ticks must be the least so they cannot be mistaken for it.

mighty_miter.core.template.OVERLAP_TAB_MM = 10.0

How far the template runs past a full wrap, in millimeters (FR-6.5).

The overlapping region is drawn dashed so the fabricator can confirm the wrap closed on itself rather than spiralling. 10 mm is enough to see the two lines agree and small enough not to widen the sheet.

class mighty_miter.core.template.TemplateLayout(origin_x_mm, origin_y_mm)[source]

Bases: object

Where the template sits on the page, in millimeters.

A pure translation from template coordinates to page coordinates. There is deliberately no scale factor: this is a 1:1 document, and the moment a scale enters the layout the calibration rectangle stops proving anything about the miter curve (PRD 8.4).

Variables:
  • origin_x_mm (float) – Page x of template x = 0 (the datum).

  • origin_y_mm (float) – Page y of template y = 0 (the axial reference).

Parameters:

Example

>>> TemplateLayout(20.0, 100.0).to_page(5.0, -3.0)
(25.0, 97.0)
origin_x_mm: float
origin_y_mm: float
to_page(x_mm, y_mm)[source]

Map a template point onto the page.

Parameters:
  • x_mm (float) – Circumferential position from the datum, in millimeters.

  • y_mm (float) – Axial position, in millimeters.

Returns:

The (x, y) page position in millimeters from the bottom-left.

Return type:

tuple[float, float]

mighty_miter.core.template.render_cylinder_template(curve, *, name, radius_mm, page_size='A4', axial_margin_mm=10.0, document_name=None, created=None)[source]

Render an unrolled cylinder miter curve as a printable 1:1 PDF (FR-6).

Parameters:
  • curve (TemplatePolyline) – The unrolled miter curve in template coordinates, from unroll_cylinder().

  • name (str) – Template name, printed on the sheet and stored as the PDF title. Any text in the WinAnsi (CP1252) range, so umlauts and the Scandinavian letters are fine (T15); see _escape_text in mighty_miter.core.export.pdf.

  • radius_mm (float) – The host tube radius in millimeters, used for the circumference check line (FR-6.7) and the tick spacing (FR-6.4).

  • page_size (str) – A key of PAGE_SIZES_MM.

  • axial_margin_mm (float) – Extra material kept beyond the miter curve’s axial extent, in millimeters (FR-6.2). The template is trimmed to the cut line, so this is the spare the fabricator wraps and holds.

  • document_name (str | None) – Source document, printed in the annotation block (FR-6.9).

  • created (str | None) – Optional PDF date string. The only variable field (ADR-012); leave None for byte-deterministic output.

Returns:

The complete PDF file.

Raises:

ExportError – If the template does not fit the page, if the radius is not positive, or if any text contains a character CP1252 cannot encode.

Return type:

bytes

Note

Orientation is chosen automatically: if the template is wider than it is tall and does not fit portrait, landscape is tried before giving up. A tube of r = 14.3 mm unrolls to 89.85 mm of circumference, which fits A4 portrait with room for the calibration block.

Example

>>> from mighty_miter.core.types import TemplatePoint, TemplatePolyline
>>> curve = TemplatePolyline(
...     (TemplatePoint(0.0, 0.0), TemplatePoint(45.0, 8.0), TemplatePoint(89.8495, 0.0))
... )
>>> pdf = render_cylinder_template(curve, name="Stay", radius_mm=14.3)
>>> pdf.startswith(b"%PDF-1.4")
True