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

1# SPDX-FileCopyrightText: 2026-present Pavel M. Penev <pavpen@gmail.com> 

2# 

3# SPDX-License-Identifier: MIT 

4 

5from typing import Self 

6 

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 

10 

11 

12class OrthonormalBasisCalculator[Vector]: 

13 """Calculates an orthonormal basis of a space that spans a given set of 

14 points 

15 

16 Example: 

17 

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

31 

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 

44 

45 def calculate(self, *, require_points_are_non_redundant: bool = False) -> Self: 

46 """Calculates, and sets `self.basis_vectors: List[Vector]` 

47 

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. 

51 

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

60 

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

89 

90 self.basis_vectors = basis_vectors 

91 

92 return self