Coverage for src \ pavpen \ geometry_calculator \ rounded_corner_calculator.py: 100%
79 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
5import math
6import warnings
7from enum import Enum
8from typing import Annotated, Self
10from pavpen.geometry_calculator.circle_calculator import CircleCalculator
11from pavpen.geometry_calculator.defaults import DEFAULT_TOLERANCE
12from pavpen.geometry_calculator.faults import (
13 AmbiguousComputationDueToPrecisionWarning,
14 ImpossibleOutputGeometryError,
15)
16from pavpen.geometry_calculator.float_vector_field_operations import FloatVectorFieldOperations
17from pavpen.geometry_calculator.orthonormal_basis_calculator import OrthonormalBasisCalculator
20class RoundedCornerCalculatorComputedValueNames(Enum):
21 CENTER = "center"
22 RADIUS = "radius"
23 ARC_START = "arc_start"
24 ARC_MIDPOINT = "arc_midpoint"
25 ARC_END = "arc_end"
28class RoundedCornerCalculator[Vector]:
29 """Calculates the parameters of a rounded corner's circular arc
31 The non-rounded corner, for which a rounded corner arc is to be
32 calculated is determined by *previous_vertex*, *corner_vertex*, and
33 *next_vertex*.
35 If *x_hat*, and *y_hat* are specified, they define the coordinate
36 system basis for outputs, such as the center of the rouded corner
37 circle, and the start and end point of the rounded corner arc.
39 :param vector_field_operations: defines how to calculate vector
40 operations for vectors, such as *previous_vertex*, *corner_vertex*, and
41 *next_vertex*
43 :param radius: the radius of the rounded corner's circle
44 """
46 def __init__(
47 self,
48 vector_field_operations: FloatVectorFieldOperations[Vector],
49 previous_vertex: Vector,
50 corner_vertex: Vector,
51 next_vertex: Vector,
52 radius: float,
53 x_hat: Vector | None = None,
54 y_hat: Vector | None = None,
55 float_tolerance: float = DEFAULT_TOLERANCE,
56 ) -> None:
57 self._vector_field_operations = vector_field_operations
58 self._float_tolerance = float_tolerance
59 self._previous_vertex = previous_vertex
60 self._corner_vertex = corner_vertex
61 self._next_vertex = next_vertex
62 self._radius = radius
63 self._x_hat = x_hat
64 self._y_hat = y_hat
66 def _calculate_basis(self) -> None:
67 """Sets ``self.x_hat``, and ``self.y_hat``"""
69 x_hat = self._x_hat
70 y_hat = self._y_hat
71 if x_hat is None:
72 basis_calculator = OrthonormalBasisCalculator(
73 vector_field_operations=self._vector_field_operations,
74 float_tolerance=self._float_tolerance,
75 points=[self._previous_vertex, self._corner_vertex, self._next_vertex],
76 ).calculate()
77 self.x_hat = basis_calculator.basis_vectors[0]
78 else:
79 self.x_hat = x_hat
80 if y_hat is None:
81 basis_calculator = OrthonormalBasisCalculator(
82 vector_field_operations=self._vector_field_operations,
83 float_tolerance=self._float_tolerance,
84 points=[self._previous_vertex, self._corner_vertex, self._next_vertex],
85 ).calculate()
86 self.y_hat = basis_calculator.basis_vectors[1]
87 else:
88 self.y_hat = y_hat
90 def calculate(self) -> Self:
91 """Calculates, and sets the folowing output parameters of the rounded
92 corner arc as attributes of ``self``:
94 * ``center``
95 * ``radius``
96 * ``arc_start``
97 * ``arc_midpoint``
98 * ``arc_end``
99 """
101 self._calculate_basis()
102 vec = self._vector_field_operations
103 float_tolerance = self._float_tolerance
104 previous_vertex = self._previous_vertex
105 corner_vertex = self._corner_vertex
106 next_vertex = self._next_vertex
107 radius = self._radius
108 corner_to_previous_vertex_vector = vec.subtracted(previous_vertex, corner_vertex)
109 corner_to_next_vertex_vector = vec.subtracted(next_vertex, corner_vertex)
110 corner_to_previous_vertex_angle_rad = math.atan2(
111 vec.projection_length_along(direction=self.y_hat, projected=corner_to_previous_vertex_vector),
112 vec.projection_length_along(direction=self.x_hat, projected=corner_to_previous_vertex_vector),
113 )
114 corner_to_next_vertex_angle_rad = math.atan2(
115 vec.projection_length_along(direction=self.y_hat, projected=corner_to_next_vertex_vector),
116 vec.projection_length_along(direction=self.x_hat, projected=corner_to_next_vertex_vector),
117 )
119 edge_to_edge_angle_rad = math.fabs(corner_to_previous_vertex_angle_rad - corner_to_next_vertex_angle_rad)
120 corner_to_center_angle_rad = (corner_to_previous_vertex_angle_rad + corner_to_next_vertex_angle_rad) / 2.0
121 difference_from_alternate_solution = math.fabs(edge_to_edge_angle_rad - math.pi)
122 if difference_from_alternate_solution < float_tolerance:
123 warnings.warn(
124 AmbiguousComputationDueToPrecisionWarning(
125 ambiguous_value_names=[
126 f"{self.__class__.__name__}.{RoundedCornerCalculatorComputedValueNames.CENTER.value}"
127 ],
128 tolerance=float_tolerance,
129 difference_from_alternate_solution=difference_from_alternate_solution,
130 ),
131 stacklevel=1,
132 )
133 if edge_to_edge_angle_rad > math.pi:
134 corner_to_center_angle_rad += math.pi
135 edge_to_edge_angle_rad = 2.0 * math.pi - edge_to_edge_angle_rad
136 center_to_edge_angle_rad = edge_to_edge_angle_rad / 2.0
137 corner_to_center_dir = vec.added(
138 vec.scaled(self.x_hat, math.cos(corner_to_center_angle_rad)),
139 vec.scaled(self.y_hat, math.sin(corner_to_center_angle_rad)),
140 )
141 corner_to_center_distance = radius / math.sin(center_to_edge_angle_rad)
142 center = vec.added(self._corner_vertex, vec.scaled(corner_to_center_dir, corner_to_center_distance))
143 circle_calculator = CircleCalculator(
144 vector_field_operations=vec,
145 center=center,
146 radius=radius,
147 x_hat=self.x_hat,
148 y_hat=self.y_hat,
149 )
151 edge_to_corner_shortening: Annotated[
152 float,
153 """Distance cut off by rounding the corner along each edge that
154 would end at the corner vertex, if there was no rounding""",
155 ] = corner_to_center_distance * math.cos(center_to_edge_angle_rad)
157 corner_to_previous_vertex_vector_norm = vec.norm(corner_to_previous_vertex_vector)
158 if corner_to_previous_vertex_vector_norm < edge_to_corner_shortening:
159 message = (
160 f"Rounded corner previous vertex is within the corner part "
161 f"cut off by rounding the corner. Values: "
162 f"previous_vertex: {previous_vertex}, "
163 f"corner_vertex: {corner_vertex}, "
164 f"next_vertex: {next_vertex}, radius: {radius}; "
165 f"corner_to_previous_vertex_vector norm: {corner_to_previous_vertex_vector_norm} < "
166 f"edge_to_corner_shortening: {edge_to_corner_shortening}"
167 )
168 raise ImpossibleOutputGeometryError(message)
169 arc_start = vec.added(
170 corner_vertex,
171 vec.scaled(
172 vec.normalized(corner_to_previous_vertex_vector),
173 edge_to_corner_shortening,
174 ),
175 )
177 corner_to_next_vertex_vector_norm = vec.norm(corner_to_next_vertex_vector)
178 if corner_to_next_vertex_vector_norm < edge_to_corner_shortening:
179 message = (
180 f"Rounded corner next vertex is within the corner part "
181 f"cut off by rounding the corner. Values: "
182 f"previous_vertex: {previous_vertex}, "
183 f"corner_vertex: {corner_vertex}, "
184 f"next_vertex: {next_vertex}, radius: {radius}; "
185 f"corner_to_next_vertex_vector norm: {corner_to_next_vertex_vector_norm} < "
186 f"edge_to_corner_shortening: {edge_to_corner_shortening}"
187 )
188 raise ImpossibleOutputGeometryError(message)
189 arc_end = vec.added(
190 corner_vertex,
191 vec.scaled(
192 vec.normalized(corner_to_next_vertex_vector),
193 edge_to_corner_shortening,
194 ),
195 )
196 arc_midpoint = circle_calculator.point_at_angle_rad(corner_to_center_angle_rad + math.pi)
198 self.center = center
199 self.radius = radius
200 self.arc_start = arc_start
201 self.arc_midpoint = arc_midpoint
202 self.arc_end = arc_end
204 return self