Skip to content

Angle readings

The trijunction angle readings: the chord, the second-order (quadric) reading, and their oriented forms.

The angle_reading (or reading) selector of every angle-reading function takes one of ANGLE_READINGS: "chord" (the default), "quadric", "oriented_chord" and "oriented_quadric". Angles are in radians in every returned map.

Why this module exists

foambryo.geometry._wedges_at_half_edge reads a trijunction wedge angle as arccos(n_i . n_j) between two incident triangles' face normals. Both normals are perpendicular to the shared junction edge, so both lie in the plane normal to it, and the angle between them equals the angle between the two triangles' in-plane traces — the straight lines from the junction out towards each incident triangle's apex. Production therefore reads a chord of each interface where the geometry has a tangent.

A flat P1 facet has no curvature to offer, so it cannot report a tangent; a circular arc's chord is rotated from its tangent by exactly kappa * L / 2, with L the in-plane distance from the junction-edge midpoint to the incident apex. This chord-for-tangent substitution was measured to account for 68.4 % of the length-weighted flattening slope of the reconstructed meshes.

This module supplies the second reading: fit a generic quadric height field to the interface's own mesh vertices in a 3-D window around each junction-edge midpoint, read that surface's normal-plane section curvature kappa analytically, and rotate the face normal about the junction edge by -kappa * L / 2. It is a local patch fit, keyed to foambryo's own trijunction identification.

The oriented wedge reading

arccos returns [0, pi], so a wedge whose interior angle exceeds 180 degrees — a reflex wedge — is reported as 360 - theta. The three wedges of one junction edge then sum to 360 - 2 (theta - 180) instead of 360, and every average along the line inherits the deficit. This is not rare on a coarse reconstruction: measured on the benchmark meshes, 2 % to 25 % of trijunctional edges carry one, at a rate that differs by a factor of six between reconstruction strategies, so a comparison of two meshes through the unoriented reading compares two different amounts of clipping as if they were geometry.

ORIENTED_CHORD_READING and ORIENTED_QUADRIC_READING return the oriented wedge angle in [0, 360): the arc from film e|f to film f|g measured through region f. The magnitude is still arccos of the same dot product, evaluated by the same expression; the oriented sine dot(t, cross(n_first, n_second)) is used only to choose between theta and 360 - theta. So the oriented readings are bitwise identical to their unoriented counterparts on every wedge that is not reflex, and the three wedges of an edge sum to 360 degrees.

The axis t runs from the lower to the higher vertex key, as everywhere else in this module. Its sign is then resolved from the geometry, not asserted: three positive angles summing to 360 contain at most one greater than 180, so under the reversed axis the three oriented wedges sum to 720 rather than 360, and the reader takes whichever assignment sums to 360. A wedge whose oriented sine is exactly zero — an interior angle of exactly 0 or exactly 180 degrees — reads as non-reflex under either axis, which reproduces the unoriented reader there.

Which inference methods this changes, and one that it breaks

MEASURED on the benchmark meshes. A method's exposure follows from the trigonometric function its rows use, because the reflection theta -> 360 - theta leaves cos unchanged and flips the sign of sin:

  • YoungDupreLocal builds every row from cos of a per-edge wedge, so it is exactly invariant -- its gauge-matched relative error moved by at most 1.6e-14 over 1035 reconstructed benchmark meshes. It could never see the defect and cannot benefit from it either; a reflex wedge and its reflection are the same row to it.
  • YoungDupre averages the wedge along the line and then takes cos of the mean, so the reflection does move it: up to 0.24 in absolute relative error, worse on 35 of the 46 reconstruction-variant cohorts measured and better on the rest.
  • ProjectionYoungDupre, Lami and InvLami use sin, which changes sign. Their error rises by 5-42 %. That is not a defect of the reading: three tensions whose wedge is reflex admit no planar equilibrium at all, and the negative sine is that fact surfacing where the unoriented reading manufactured a plausible positive number instead.
  • LogLami fails outright on a mesh with a reflex wedge: its rows take log(sin(theta)), which is nan for a negative sine, and the solve then raises. Measured on 115 of 360 benchmark meshes. Pair LogLami with an unoriented reading, or repair the reflex edges upstream.

The quadric window needs no reflex guard. It is defined per incident face, transverse to the junction along that face's own chord, and never refers to a wedge angle at all; a reflex wedge is a relation between two faces, so it cannot enter one face's window. What the quadric reading does do is rotate normals about the junction edge, which moves some wedges across 180 degrees in either direction, so its reflex count differs from the chord reading's on the same mesh. That is a property of the corrected geometry, not a defect of the window.

The two junction-edge endpoints

The fit keeps a sample when it is inside the window and on the interface's own side of the junction, eta > 0. Two of the candidate samples are the junction edge's own two endpoints, and in exact arithmetic their eta is exactly zero — they sit on the eta = 0 axis of the frame by construction, being the two vertices whose midpoint is the frame's origin. A strict eta > 0.0 therefore resolves them on the sign of a rounding error, and the sign of a rounding error depends on the units the mesh is expressed in.

MEASURED, on the benchmark's reference meshes: rescaling one mesh by 117x — the ratio between the normalised frame those meshes are stored in and the voxel frame their reconstructions live in — changed the kept-sample count on 652 of 1 065 incident faces, changed the admission outcome on 19, and moved line-mean wedge angles by up to 0.14 degrees. The chord reading moves by 2.5e-13 degrees under the same rescaling. That is about eleven orders of magnitude of difference between two readings of one mesh, and it is why the same reference meshes have been published at 2.032 and at 2.046 degrees of contact-angle error under one name: the frame a mesh is in was part of that reading's construction and was never stated with it.

An angle between two surfaces cannot depend on the units the surfaces are measured in, so this is a correctness defect independently of any angle metric. JUNCTION_ENDPOINTS decides those two samples by vertex identity instead. They are included: they are exact, noise-free samples of the interface at the very point the frame is anchored, they constrain the along-edge coefficients that nothing else near eta = 0 constrains, and including them is a statement rather than an accident. "exclude" and the historical "rounding" remain reachable through quadric_angle_rotations so that the repair can be measured; neither is reachable through any reading selector or any inference entry point.

MEASURED after the repair, on the same reference mesh: rescaling by 10, 117 and 1000 moves the line-mean wedge angles by at most 2.3e-13 degrees, against the chord reading's own 2.5e-13. The two readings are now equally invariant, to rounding. On a smooth surface whose contact angles are a closed form the repair moves the reading itself by at most 0.016 degrees and by about 0.001 degrees at typical spacings — so it is a repair whose value is the invariance, not an accuracy gain, and saying so is part of the repair.

The constant term, and the false reason it used to be omitted

The height field is fitted without a constant term. The reason recorded here used to be that "the junction-edge midpoint is the midpoint of two mesh vertices lying on this interface, so the interface passes through the origin by construction". That reason is false whenever the junction line is curved, which on any real mesh it is: the midpoint of two vertices lies on the junction edge's chord, and the line leaves that chord by about kappa_line * L^2 / 8, as does the interface bounded by it. The frame's origin is a point the surface does not contain.

The real reason, MEASURED rather than argued, on a smooth surface whose contact angles are a closed form and whose triangulation was varied over fourteen edge lengths spanning a factor of seventeen:

  • fitting the constant term makes the reading better at every fine triangulation — up to 43 % lower line-mean error at the finest, and better at ten of the fourteen edge lengths tried;
  • and worse at the four coarsest, by up to 7.4 degrees, because at those spacings the extra free parameter is estimated from a window holding barely twice as many samples as the fit has parameters, and the variance it adds exceeds the bias it removes;
  • the coarse spacings the software is actually run at may or may not be on the worsening side: that depends on how curved real interfaces are relative to their triangulation, which is not a number this project has measured.

So the term stays off — not because the surface passes through the origin, which it does not, but because switching it on is an improvement at fine triangulations bought with a degradation at coarse ones, and the operating point is not known well enough to take that trade. fit_constant_term=True turns it on for measurement. Turning it on also raises the minimum admissible sample count from 10 to 12, which lowers coverage on meshes whose windows are thinly populated. It does not change _section_curvature, because translating a plane curve does not change its curvature.

What would settle it: the same fourteen-rung measurement with the analytic films' curvature matched to real interfaces', or the equivalent measurement on reconstructions with a ground-truth angle. Either would say which side of the crossover the shipped spacings are on.

Units and conventions

  • Coordinates are in the mesh's own units: voxel indices on dw3d output reconstructed without a spacing, the spacing's unit with one. kappa is per mesh length unit and kappa * L / 2 is dimensionless (an angle in radians); the reading does not depend on the unit (see "The two junction-edge endpoints").
  • kappa is the signed in-plane curvature of the interface's normal-plane section at the junction, positive when the section turns counter-clockwise about the junction-edge direction (lower vertex key -> higher vertex key) as it leaves the junction.
  • The junction-edge axis runs from the lower to the higher vertex key. Using a half-edge's own direction instead would flip the sign of the correction on half the half-edges.

Which correction form is applied, and which is not

Only the closed form -kappa * L / 2 is applied. It was measured that the alternative — rotating the mesh's chord onto the fitted patch's own tangent — injects a zero-mean scatter of about 3.3 degrees at the median and 14.4 degrees at p90 on this patch fit, because a patch fit's absolute orientation carries the discrepancy between the fitted surface and the mesh's local orientation, which is not a chord-versus-tangent error and must not be rotated away. That form is not implemented here. The cost of the form that is implemented is that the circular-arc model becomes load-bearing: -kappa * L / 2 is exact for an arc and first-order in kappa * L otherwise.

What enters the fit, exhaustively

The fit is generic: a quadric height field is the second-order Taylor statement about whatever surface is there, and injects only smoothness. No constant-mean-curvature constraint, no Laplace or Neumann relaxation, no tension, no pressure, no area, no volume, no ground-truth angle, no reference mesh, no .rec file, no segmentation mask. The complete list of inputs is:

  1. mesh.v, the mesh's vertex coordinates;
  2. mesh.f, its triangles and their two material labels;

and nothing else. quadric_angle_rotations takes a DcelData and, besides the window's measurement parameters, no other argument, so "no ground truth in the estimator" is structural here: there is no parameter through which a reference mesh, a ground-truth angle, a tension or a pressure could reach it.

Identity at zero curvature

A fitted kappa of exactly 0.0 produces a rotation of exactly -0.0, and geometry._wedges_at_half_edge skips the Rodrigues call on a falsy angle, so the chord angle is returned bitwise. Pinned by tests/test_second_order_angle_reading.py, which also checks the stronger statement that an explicitly all-zero rotation map, and an empty one, are the identity to the bit.

The honest qualification, MEASURED rather than asserted: a geometrically flat interface does not fit to an exactly-zero c_ee, because the least-squares design matrix is not exactly rank-consistent in binary floating point. On a planar three-film phantom the residual rotation peaks at 4.1e-17 rad (2.3e-15 degrees) and moves a reported wedge angle by at most 2.8e-14 degrees — sixteen orders of magnitude below the ~5 degree per-line errors this reading exists to reduce, and below float64's own resolution at 120 degrees. So the bitwise statement is about kappa == 0, and the practical statement about a flat surface is "identity to 1e-14 degrees".

Hyperparameters are fixed in production, and measurable outside it

QUADRIC_WINDOW_FACTOR = 4.0 and QUADRIC_AXIAL_FACTOR = 1.0 are fixed a priori (the axial one is the isotropic choice, which assumes least). Re-selecting them by held-out fit residual, with at least 85 % of incident faces covered, returns exactly these values, so they carry no selection freedom with respect to the metrics they are judged on.

They remain module constants, and no production path can change them: no reading selector, no inference entry point, no public convenience function and not rotations_for_reading takes a window. quadric_angle_rotations alone accepts them, keyword-only, defaulting to these constants, so that the cost of the window can be measured without a second implementation of the fit living outside this module — two implementations of one fit diverge, and this project has been bitten by that. Called at the defaults it is bitwise what it was before the parameters existed, which tests/test_second_order_angle_reading.py executes rather than asserts.

The prohibition that motivated keeping them constants is unchanged and is about selection, not about reachability: a window must never be chosen on the contact-angle flattening slope, the tension contrast or the per-line angle error of a mesh whose accuracy is being judged. A window may be chosen only on meshes that are not on trial — which is what makes measuring it admissible at all.

Coverage, and why the fallback is reported rather than hidden

The fit is not admissible everywhere: an incident face whose window holds fewer than two mesh vertices per fit parameter, or whose design matrix is rank-deficient, gets no rotation and falls back to the chord reading. Measured on dw3d's 47-case benchmark at min_distance = 3, coverage is 0.919 of incident faces; at min_distance = 4 it is 0.890. A result that mixes corrected and uncorrected angles without saying so would be unreadable as science, so quadric_angle_rotations returns an AngleReadingReport naming the coverage and every trijunction line that is not fully corrected, and the report-less convenience paths in foambryo.geometry and foambryo.tension_inference emit an AngleReadingCoverageWarning instead of staying silent.

Measured behaviour

The measured behaviour of the reading is in BENCHMARKS.md (foambryo repository).

AngleReadingCoverageWarning

Bases: UserWarning

Emitted when a quadric reading corrects only part of a mesh's trijunction faces.

Applies to "quadric" and "oriented_quadric"; the chord readings always cover every face.

The uncorrected faces silently fall back to the chord reading, which mixes two angle conventions in one result. That is admissible — it is strictly better than dropping the lines — but it must never be invisible. Use the *_with_report entry points in foambryo.geometry and foambryo.tension_inference to receive the numbers instead of the warning.

AngleReadingReport dataclass

AngleReadingReport(reading: str, n_incident_faces: int, n_corrected: int, n_no_samples: int, n_no_fit: int, coverage_fraction: float | None, uncorrected_lines: tuple[tuple[int, ...], ...], line_coverage: dict[tuple[int, ...], tuple[int, int]] = dict(), window_factor: float = QUADRIC_WINDOW_FACTOR, axial_factor: float = QUADRIC_AXIAL_FACTOR, junction_endpoints: str = JUNCTION_ENDPOINTS, fit_constant_term: bool = FIT_CONSTANT_TERM)

What a trijunction angle reading covered, and what it fell back on.

Attributes:

Name Type Description
reading str

one of ANGLE_READINGS. Either chord reading always reports full coverage: it has no fit to fail, and it does not walk the junctions a second time, so its per-face counts are 0 and carry no information — only coverage_fraction, uncorrected_lines and is_complete are meaningful on a chord report.

n_incident_faces int

(trijunctional geometric edge, incident face) pairs visited. Three per trijunctional edge; quadrijunctional edges are skipped, exactly as foambryo.geometry.compute_angles_tri skips them.

n_corrected int

pairs that received a second-order correction.

n_no_samples int

pairs whose window held too few interface vertices to fit.

n_no_fit int

pairs whose design matrix was rank-deficient or whose curvature was not finite.

coverage_fraction float | None

n_corrected / n_incident_faces, or None on a mesh with no trijunctional edge at all.

uncorrected_lines tuple[tuple[int, ...], ...]

every trijunction line — identified by its sorted material triple, the same three materials whose wedge keys compute_angles_tri returns — carrying at least one uncorrected face. Sorted.

line_coverage dict[tuple[int, ...], tuple[int, int]]

material triple -> (corrected faces, total faces) for every trijunction line in the mesh.

window_factor float

the transverse window multiplier actually used.

axial_factor float

the axial window multiplier actually used.

junction_endpoints str

the policy actually used for the frame's own two junction-edge endpoints, one of JUNCTION_ENDPOINT_POLICIES.

fit_constant_term bool

whether the quadric height field actually fitted a constant term.

A trijunction line with partial coverage (some but not all of its faces corrected) is not, by itself, a known source of extra angle error: measured against the chord reading's own error on the same lines, the per-line displacement this correction makes does not trend with how much of the line was covered (a near-zero, statistically insignificant slope, most of what little apparent trend there is already present in the chord reading, which has no coverage concept at all). A low line_coverage_fraction is a coverage fact about the mesh, not a warning sign about the correction's accuracy.

is_complete property

is_complete: bool

True when every trijunctional incident face was read with the selected reading.

line_coverage_fraction property

line_coverage_fraction: float | None

Fraction of trijunction lines every one of whose faces was corrected.

The line analogue of coverage_fraction, which is per-face: a line with 2 of its 3 faces corrected counts as 0 here (it is in uncorrected_lines) but contributes 2/3 to coverage_fraction. None on a mesh with no trijunctional edge at all, mirroring coverage_fraction's own convention.

describe

describe() -> str

A one-line human-readable summary, used as the coverage warning's message.

Leads with the two numbers a reader needs to judge the result: the measured face coverage fraction and how many trijunction lines were not fully corrected (out of how many), then lists those lines (at most _MAX_LINES_IN_WARNING of them).

fits_quadric_tangents

fits_quadric_tangents(reading: str) -> bool

Whether reading replaces each triangle's chord by a fitted quadric tangent.

Parameters:

Name Type Description Default
reading str

one of ANGLE_READINGS.

required

Returns:

Name Type Description
bool bool

True for "quadric" and "oriented_quadric", False for the two chord readings.

Raises:

Type Description
ValueError

if reading is not one of ANGLE_READINGS.

reads_oriented_wedges

reads_oriented_wedges(reading: str) -> bool

Whether reading returns the oriented wedge angle in [0, 360) rather than [0, 180].

See "The oriented wedge reading" in this module's docstring for what the two ranges mean and why they differ only on reflex wedges.

Parameters:

Name Type Description Default
reading str

one of ANGLE_READINGS.

required

Returns:

Name Type Description
bool bool

True for "oriented_chord" and "oriented_quadric".

Raises:

Type Description
ValueError

if reading is not one of ANGLE_READINGS.

warn_on_partial_coverage

warn_on_partial_coverage(report: AngleReadingReport, stacklevel: int = 3) -> None

Emit an AngleReadingCoverageWarning when a reading fell back on part of the mesh.

Called only from the entry points that drop the report on the floor — the report-returning ones hand the caller the numbers the warning would have described, and stay silent. Complete coverage is silent either way.

Parameters:

Name Type Description Default
report AngleReadingReport

the reading's coverage report.

required
stacklevel int

passed through to warnings.warn, so the warning points at the caller's own line rather than at this module. Defaults to 3.

3

validate_reading

validate_reading(reading: str) -> str

Check an angle-reading selector and return it.

Parameters:

Name Type Description Default
reading str

the selector to check.

required

Returns:

Name Type Description
str str

reading, unchanged.

Raises:

Type Description
ValueError

if reading is not one of ANGLE_READINGS.

orthonormal_basis

orthonormal_basis(normal: NDArray[float64]) -> tuple[NDArray[np.float64], NDArray[np.float64]]

A right-handed in-plane basis (e1, e2) with cross(e1, e2) = normal.

e1 is built from the world axis least aligned with normal, which is deterministic and never degenerate. Handedness matters: it fixes the sign of every curvature this module returns, and it is the reason rodrigues_about and the in-plane angles agree (cross(axis, e1) = e2 for this basis).

Parameters:

Name Type Description Default
normal NDArray[float64]

the plane's normal, any non-zero length.

required

Returns:

Type Description
tuple[NDArray[float64], NDArray[float64]]

tuple[NDArray[np.float64], NDArray[np.float64]]: the unit vectors (e1, e2).

rodrigues_about

rodrigues_about(axis: NDArray[float64], vector: NDArray[float64], angle: float) -> NDArray[np.float64]

Rotate vector about the unit axis by angle, counter-clockwise seen from +axis.

For a vector already perpendicular to the axis — which every face normal at a junction edge is — this reduces to v cos a + (axis x v) sin a, and it agrees with a counter-clockwise rotation in the (e1, e2) basis orthonormal_basis returns, because cross(axis, e1) = e2 for that right-handed basis.

The rotation is linear, so it acts identically on n and on -n: applying it before _wedges_at_half_edge's relabelling flip and applying it after are the same operation, and the sign the mesh happens to carry on a face normal does not enter.

Parameters:

Name Type Description Default
axis NDArray[float64]

unit rotation axis.

required
vector NDArray[float64]

the vector to rotate.

required
angle float

rotation angle in radians.

required

Returns:

Type Description
NDArray[float64]

NDArray[np.float64]: the rotated vector.

junction_frames

junction_frames(mesh: DcelData) -> list[dict]

One local frame per (trijunctional geometric edge, incident face), from the DCEL.

This walk is production's own trijunction identification, not a second one. A geometric edge is visited when at least one of its half-edges has more than one twin (the test compute_angles_tri and compute_trijunction_edges use), it is de-duplicated by its (min, max) vertex-key pair keeping the lowest incident half-edge, and its three incident faces are collected in exactly the order geometry._wedges_at_half_edge collects them — the half-edge's own face first, then its twins'. An edge with any number of incident faces other than three is a quadrijunction (or higher) and is skipped, exactly as production skips it. Keying the frames this way is what makes every corrected angle attributable to the same (trijunction, material) key the chord reading produces.

The frame at one edge:

  • axis runs from the lower to the higher vertex key, normalised;
  • midpoint is the edge midpoint, which is where production's chord is anchored;
  • (e1, e2) is orthonormal_basis(axis), whose handedness fixes every curvature sign;
  • chord is the in-plane unit vector from midpoint towards the incident face's apex — production's own reading direction — and lever is the in-plane distance to that apex.

Parameters:

Name Type Description Default
mesh DcelData

the mesh to walk.

required

Returns:

Type Description
list[dict]

list[dict]: one entry per (edge, face) with keys edge, face, axis, midpoint, e1, e2, lever, chord, edge_length, pair (the face's sorted material pair) and materials (the edge's sorted material triple). Faces whose apex lies on the junction axis (lever == 0) are dropped: they have no chord to correct.

quadric_angle_rotations

quadric_angle_rotations(mesh: DcelData, *, window_factor: float = QUADRIC_WINDOW_FACTOR, axial_factor: float = QUADRIC_AXIAL_FACTOR, junction_endpoints: str = JUNCTION_ENDPOINTS, fit_constant_term: bool = FIT_CONSTANT_TERM) -> tuple[dict[tuple[tuple[int, int], int], float], AngleReadingReport]

Per-(edge, face) normal rotations that turn the chord reading into the quadric one.

For each (trijunctional geometric edge, incident face) this fits a generic quadric height field to the incident interface's own mesh vertices inside an anisotropic window around the junction-edge midpoint, reads the normal-plane section's signed curvature kappa off the fitted surface, and returns the rotation -kappa * lever / 2 to apply to that face's normal about the junction-edge axis. That is the exact chord-to-tangent rotation for a circular arc of curvature kappa whose chord has in-plane length lever.

The window is window_factor * lever transversely and axial_factor times that along the junction edge. A sample is kept when |xi| <= axial window and hypot(eta, zeta) <= window, and then when eta > 0 — which keeps the fit on the side of the junction where the interface actually lies, since an interface is bounded by its junction line — except for the frame's own two junction-edge endpoints, whose membership junction_endpoints decides explicitly. See "The two junction-edge endpoints" in this module's docstring for why that exception is a correctness fix and not a tuning knob.

Failure modes, all of which fall back to the chord reading for that face and are counted in the returned report: fewer than _SAMPLES_PER_PARAMETER * 5 interface vertices in the window, a rank-deficient design matrix, or a non-finite curvature.

Determinism: the walk, the k-d trees and the least-squares solve are all deterministic functions of mesh.v, mesh.f and the four keyword arguments; there is no random seed anywhere in this module.

The two window factors are measurement affordances, not tuning knobs, and no production path can reach them. They default to the module constants, rotations_for_reading does not forward them, and no reading selector, inference entry point or public convenience function exposes them; a caller who wants a different window has to name this function and say so. They exist because measuring what the window costs otherwise requires re-implementing the fit outside this module, and two implementations of one fit diverge. Called at the defaults the function is bitwise what it was before the parameters existed — the same float in every entry of the same map — and tests/test_second_order_angle_reading.py executes that identity rather than asserting it. Choosing a window on any quantity an arm is judged by would be the tuning this module's own docstring prohibits; a window may be chosen only on meshes that are not being judged.

Parameters:

Name Type Description Default
mesh DcelData

the mesh to read, in any length unit. The only mesh-derived argument: see "What enters the fit" in the module docstring. No reference mesh, target angle, tension or pressure can reach this function.

required
window_factor float

transverse window, as a multiple of the incident face's own in-plane lever. Keyword-only. Defaults to QUADRIC_WINDOW_FACTOR, and production never passes anything else.

QUADRIC_WINDOW_FACTOR
axial_factor float

extent along the junction edge, as a multiple of the transverse window. Keyword-only. Defaults to QUADRIC_AXIAL_FACTOR.

QUADRIC_AXIAL_FACTOR
junction_endpoints str

one of JUNCTION_ENDPOINT_POLICIES. Keyword-only. Defaults to JUNCTION_ENDPOINTS, which is "include". "rounding" reproduces the historical behaviour, in which these two samples' membership was decided by the sign of a rounding error; it exists so that the repair can be measured, not so that it can be chosen.

JUNCTION_ENDPOINTS
fit_constant_term bool

fit the quadric's constant term. Keyword-only. Defaults to FIT_CONSTANT_TERM. False reproduces the historical five-parameter fit, including its lower minimum sample count, exactly.

FIT_CONSTANT_TERM

Raises:

Type Description
ValueError

if junction_endpoints is not one of JUNCTION_ENDPOINT_POLICIES.

Returns:

Type Description
tuple[dict[tuple[tuple[int, int], int], float], AngleReadingReport]

tuple[dict[tuple[tuple[int, int], int], float], AngleReadingReport]: the rotation angles in radians keyed by ((low vertex key, high vertex key), face key), and the coverage report. A key absent from the map means "no admissible fit here, read the chord".

rotations_for_reading

rotations_for_reading(mesh: DcelData, reading: str) -> tuple[dict[tuple[tuple[int, int], int], float] | None, AngleReadingReport]

Dispatch on the reading selector, with a per-mesh cache for the quadric fit.

The quadric fit costs one k-d tree query and one 5-parameter least-squares solve per incident trijunction face — measured at roughly 0.5 s on a 47-case benchmark mesh, which is the same order as one inference. Every angle-reading tension method calls compute_angles_tri once, so a benchmark that runs eight methods over one mesh would pay for the fit eight times. The result is cached on the mesh instance under _angle_reading_rotations.

Cache validity. DcelData never mutates v or f after __init__, so the cache cannot go stale through any public method of this package. If you mutate those arrays yourself, del mesh._angle_reading_rotations first.

The cache is shared by "quadric" and "oriented_quadric": the rotations depend only on the mesh, and whether a wedge is then reported oriented is decided downstream, in foambryo.geometry. Only the returned report's reading label differs.

This function does not forward a window. It calls quadric_angle_rotations with no window arguments, so every reading reachable through a reading selector uses QUADRIC_WINDOW_FACTOR and QUADRIC_AXIAL_FACTOR. A measurement that wants another window calls quadric_angle_rotations itself; it may then seed this function's cache by assigning the pair to mesh._angle_reading_rotations, and the report it stores names the window it used, so a result read that way says so rather than looking like production's.

Parameters:

Name Type Description Default
mesh DcelData

the mesh to read.

required
reading str

one of ANGLE_READINGS.

required

Returns:

Type Description
tuple[dict[tuple[tuple[int, int], int], float] | None, AngleReadingReport]

tuple[dict | None, AngleReadingReport]: the rotation map (None for either chord reading, which applies no rotation at all) and the coverage report.

Raises:

Type Description
ValueError

if reading is not one of ANGLE_READINGS.