Coverage for src \ pavpen \ geometry_calculator \ orthonormal_basis_calculator.py: 100%
34 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-27 03:59 -0400
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-27 03:59 -0400
1# SPDX-FileCopyrightText: 2026-present Pavel M. Penev <pavpen@gmail.com>
2#
3# SPDX-License-Identifier: MIT
5from typing import Self
7from pavpen.geometry_calculator.defaults import DEFAULT_TOLERANCE
8from pavpen.geometry_calculator.faults import CalculationPrecisionExceededError
9from pavpen.geometry_calculator.float_vector_field_operations import FloatVectorFieldOperations
12class OrthonormalBasisCalculator[Vector]:
13 """Calculates an orthonormal basis of a space that spans a given set of
14 points
16 Example:
18 >>> import math
19 >>> from pavpen.geometry_calculator import OrthonormalBasisCalculator
20 >>> from pavpen.geometry_calculator.vector_field_implementations import (
21 ... TupleFloatVectorFieldOperations,
22 ... )
23 >>>
24 >>> OrthonormalBasisCalculator(
25 ... vector_field_operations=TupleFloatVectorFieldOperations.for_3d(),
26 ... float_tolerance=1e-8,
27 ... points=((0, 0, 0), (0, 2, 0), (0, 0, -2)),
28 ... ).calculate().basis_vectors
29 [(0.0, 1.0, 0.0), (0.0, 0.0, -1.0)]
30 """
32 def __init__(
33 self,
34 vector_field_operations: FloatVectorFieldOperations[Vector],
35 points: list[Vector],
36 float_tolerance: float = DEFAULT_TOLERANCE,
37 ) -> None:
38 self._vector_field_operations = vector_field_operations
39 self._points = points
40 if len(points) < 1:
41 message = f"The `points` argument must contain at least one element. len(points): {len(points)}"
42 raise ValueError(message)
43 self._float_tolerance = float_tolerance
45 def calculate(self, *, require_points_are_non_redundant: bool = False) -> Self:
46 """Calculates, and sets `self.basis_vectors: List[Vector]`
48 The values in `basis_vectors` are orthonormal vectors spanning the
49 space that contains the `points` passed in during construction. Two
50 non-redundant points define a line, three points define a plane, etc.
52 Setting `require_points_are_non_redundant` to `True` makes it an
53 exception for `points` to be spanned by less than `len(points) - 1`
54 basis vectors. In this case [CalculationPrecisionExceededError] is
55 raised, if two points are too close to each other, or if two different
56 pairs of points define directions that are close to colinear, or if a
57 computation's maximum tolerance exceeds `float_tolerance` (specified
58 during object construction) for any other reason.
59 """
61 points = self._points
62 try:
63 [first_point, *rest_points] = points
64 except ValueError as e:
65 e.add_note(
66 f"The `points` collection in "
67 f"{OrthonormalBasisCalculator.__name__} must contain at "
68 f"least one element. len(points): {len(points)}"
69 )
70 raise
71 vec = self._vector_field_operations
72 basis_vectors: list[Vector] = []
73 for point in rest_points:
74 non_spanned_component = vec.subtracted(point, first_point)
75 for basis_vector in basis_vectors:
76 projection_on_basis_vector_size = vec.inner_multiplied(non_spanned_component, basis_vector)
77 projection_on_basis_vector = vec.scaled(basis_vector, projection_on_basis_vector_size)
78 non_spanned_component = vec.subtracted(non_spanned_component, projection_on_basis_vector)
79 non_spanned_component_norm = vec.norm(non_spanned_component)
80 if non_spanned_component_norm < self._float_tolerance:
81 if require_points_are_non_redundant:
82 raise CalculationPrecisionExceededError(
83 tolerance=self._float_tolerance,
84 calculation_error=float("inf"),
85 value_name="non_spanned_component_norm",
86 )
87 else:
88 basis_vectors.append(vec.scaled(non_spanned_component, 1 / non_spanned_component_norm))
90 self.basis_vectors = basis_vectors
92 return self