simetri.geometry.hobby

  1import numpy as np
  2import cmath
  3
  4from ..graphics.shape import Shape
  5from ..geometry.bezier import bezier_points
  6
  7"""
  8Implementation of John Hobby's Bezier curve algorithm in Python.
  9The algorithm is very simple and efficient. Details are on page 112, 113 in Knuth's METAFONT: The Program.
 10"""
 11# Taken from https://github.com/ltrujello/Hobby_Curve_Algorithm 2/7/2025
 12
 13
 14class HobbyPoint(complex):
 15    """A class for associating numerical quantities from Hobby's algorithm with points that appear on a Hobby curve.
 16
 17    We subclass `complex` to perform complex arithmetic with points on the Hobby curve, as required in the algorithm.
 18
 19    Attributes:
 20        x (float): The x-coordinate of the point.
 21        y (float): The y-coordinate of the point.
 22        alpha (float): The reciprocal of tension for incoming segment.
 23        beta (float): The reciprocal of tension for outgoing segment.
 24        d_val (float): Distance between this point and next.
 25        theta (float): Angle of polygonal line from this point to next.
 26        phi (float): Offset angle.
 27        psi (float): Another offset angle.
 28    """
 29
 30    def __new__(cls, x: float, y: float, tension: float) -> "HobbyPoint":
 31        """Create a new instance of HobbyPoint.
 32
 33        Args:
 34            x: The x-coordinate of the point.
 35            y: The y-coordinate of the point.
 36            tension: The tension value for the curve.
 37
 38        Returns:
 39            A new HobbyPoint instance with complex value (x + y*j).
 40        """
 41        return super().__new__(cls, x, y)
 42
 43    def __init__(self, x: float, y: float, tension: float) -> None:
 44        """Initialize a HobbyPoint with coordinates and tension.
 45
 46        Args:
 47            x: The x-coordinate of the point.
 48            y: The y-coordinate of the point.
 49            tension: The tension value for the curve.
 50        """
 51        self.x = x
 52        self.y = y
 53        # In what follows, we use Knuth's notation in our variable names.
 54        self.alpha = 1 / tension
 55        self.beta = 1 / tension
 56        self.d_val = 0  # Distance between this point and next.
 57        self.theta = 0  # Angle of polygonal line from this point to next.
 58        self.phi = 0  # Offset angle.
 59        self.psi = 0  # Another offset angle.
 60
 61    def debug_info(self) -> str:
 62        """Return a string with the point's information.
 63
 64        Returns:
 65            A string containing the point's coordinates and all of its computational values.
 66        """
 67        return (
 68            f"{(self.x, self.y)} "
 69            f"alpha={self.alpha}, "
 70            f"beta={self.beta}, "
 71            f"theta={self.theta}, "
 72            f"psi={self.psi}, "
 73            f"phi={self.phi}, "
 74            f"d_val={self.d_val}"
 75        )
 76
 77    def __repr__(self) -> str:
 78        """Return a string representation of the point.
 79
 80        Returns:
 81            A string representation of the point's coordinates.
 82        """
 83        return f"{(self.x, self.y)}"
 84
 85
 86class HobbyCurve:
 87    """A class for calculating the control points required to draw a Hobby curve.
 88
 89    Attributes:
 90        points (list[HobbyPoint]): The list of points defining the curve.
 91        ctrl_pts (list[tuple]): The calculated control points.
 92        is_cyclic (bool): Whether the curve is closed.
 93        begin_curl (float): Curl value for the beginning of the curve.
 94        end_curl (float): Curl value for the end of the curve.
 95        n_points (int): Number of points in the curve.
 96        debug_mode (bool): Whether to print debug information.
 97    """
 98
 99    def __init__(
100        self,
101        points: list[tuple],
102        tension: float = 1,
103        cyclic: bool = False,
104        begin_curl: float = 1,
105        end_curl: float = 1,
106        debug: bool = False,
107    ) -> None:
108        """Initialize a HobbyCurve with the given parameters.
109
110        Args:
111            points: List of (x, y) tuples representing the curve's points.
112            tension: Tension parameter controlling the "tightness" of the curve.
113            cyclic: Whether the curve should be closed.
114            begin_curl: Curl value for the beginning of the curve.
115            end_curl: Curl value for the end of the curve.
116            debug: Whether to print debug information.
117
118        Raises:
119            ValueError: If fewer than 2 points are provided.
120        """
121        if len(points) < 2:
122            raise ValueError("Algorithm needs more than 2 points")
123        self.points = [HobbyPoint(*point, tension) for point in points]
124        self.ctrl_pts = []
125        self.is_cyclic = cyclic
126        self.begin_curl = begin_curl
127        self.end_curl = end_curl
128        self.n_points = len(points)
129        self.debug_mode = debug
130
131    def get_ctrl_points(self) -> list[tuple]:
132        """Calculate and return all of the control points of the Hobby curve.
133
134        Executes the Hobby algorithm by calculating distance values, psi values,
135        theta values, and phi values, then uses these to compute control points.
136
137        Returns:
138            A list of (x, y) tuples representing the Bezier control points.
139        """
140        self.calculate_d_vals()
141        self.calculate_psi_vals()
142        self.calculate_theta_vals()
143        self.calculate_phi_vals()
144        self.show_debug_msg()
145        self.ctrl_pts = self.calculate_ctrl_pts()
146        return self.ctrl_pts
147
148    def calculate_d_vals(self) -> None:
149        """Calculate the pairwise distances between consecutive points in the curve."""
150        # Skip last point if path is non-cyclic
151        point_inds = (
152            range(self.n_points) if self.is_cyclic else range(self.n_points - 1)
153        )
154        for i in point_inds:
155            z_i = self.points[i % self.n_points]
156            z_j = self.points[(i + 1) % self.n_points]
157            z_i.d_val = abs(z_i - z_j)
158
159    def calculate_psi_vals(self) -> None:
160        """Calculate the psi values by finding the angle of the polygonal turns.
161
162        Raises:
163            ZeroDivisionError: If consecutive points have the same coordinates.
164        """
165        # Skip first and last point if path is non-cyclic
166        point_inds = (
167            range(self.n_points) if self.is_cyclic else range(1, self.n_points - 1)
168        )
169        for i in point_inds:
170            z_h = self.points[i - 1]
171            z_i = self.points[i]
172            z_j = self.points[(i + 1) % self.n_points]
173            try:
174                polygonal_turn = (z_j - z_i) / (z_i - z_h)
175                # print(z_j - z_i, z_i - z_h)
176            except ZeroDivisionError:
177                raise ZeroDivisionError(
178                    f"Consecutive points {(z_h.x, z_h.y)} and {(z_i.x, z_i.y)} cause zero division."
179                )
180            z_i.psi = np.arctan2(polygonal_turn.imag, polygonal_turn.real)
181
182    def calculate_theta_vals(self) -> None:
183        """Calculate the theta values by solving a linear system of equations.
184
185        This is the core of Hobby's algorithm, creating and solving a system of equations
186        to find the optimal angles for smooth splines.
187        """
188        A = np.zeros(
189            self.n_points
190        )  # Inappropriate names, but they mirror Knuth's notation.
191        B = np.zeros(self.n_points)
192        C = np.zeros(self.n_points)
193        D = np.zeros(self.n_points)
194        R = np.zeros(self.n_points)
195
196        # Calculate the entries of the five vectors.
197        # Skip first and last point if path is non-cyclic.
198        point_ind = (
199            range(self.n_points) if self.is_cyclic else range(1, self.n_points - 1)
200        )
201        for i in point_ind:
202            z_h = self.points[i - 1]
203            z_i = self.points[i]
204            z_j = self.points[(i + 1) % self.n_points]
205
206            A[i] = z_h.alpha / (z_i.beta**2 * z_h.d_val)
207            B[i] = (3 - z_h.alpha) / (z_i.beta**2 * z_h.d_val)
208            C[i] = (3 - z_j.beta) / (z_i.alpha**2 * z_i.d_val)
209            D[i] = z_j.beta / (z_i.alpha**2 * z_i.d_val)
210            R[i] = -B[i] * z_i.psi - D[i] * z_j.psi
211
212        # Set up matrix M such that the soln. Mx = R are the theta values.
213        M = np.zeros((self.n_points, self.n_points))
214        for i in range(self.n_points):
215            # Fill i-th row of M
216            M[i][i - 1] = A[i]
217            M[i][i] = B[i] + C[i]
218            M[i][(i + 1) % self.n_points] = D[i]
219
220        # Special formulas for first and last rows of M with non-cyclic paths.
221        if not self.is_cyclic:
222            # First row of M
223            alpha_0 = self.points[0].alpha
224            beta_1 = self.points[1].beta
225            xi_0 = (alpha_0**2 * self.begin_curl) / beta_1**2
226            M[0][0] = alpha_0 * xi_0 + 3 - beta_1
227            M[0][1] = (3 - alpha_0) * xi_0 + beta_1
228            R[0] = -((3 - alpha_0) * xi_0 + beta_1) * self.points[1].psi
229            # Last row of M
230            alpha_n_1 = self.points[-2].alpha
231            beta_n = self.points[-1].beta
232            xi_n = (beta_n**2 * self.end_curl) / alpha_n_1**2
233            M[-1][-2] = (3 - beta_n) * xi_n + alpha_n_1
234            M[-1][-1] = beta_n * xi_n + 3 - alpha_n_1
235            R[-1] = 0
236
237        # Solve for theta values.
238        thetas = np.linalg.solve(M, R)
239        for i, point in enumerate(self.points):
240            point.theta = thetas[i]
241
242    def calculate_phi_vals(self) -> None:
243        """Calculate the phi values using the relationship theta + phi + psi = 0."""
244        for point in self.points:
245            point.phi = -(point.psi + point.theta)
246
247    def calculate_ctrl_pts(self) -> list[tuple]:
248        """Calculate the Bezier control points between consecutive points.
249
250        Returns:
251            A list of (x, y) tuples representing the control points, with two control points
252            for each curve segment between consecutive points.
253        """
254        ctrl_pts = []
255        # Skip last point if path is non-cyclic
256        point_inds = (
257            range(self.n_points) if self.is_cyclic else range(self.n_points - 1)
258        )
259        for i in point_inds:
260            z_i = self.points[i]
261            z_j = self.points[(i + 1) % self.n_points]
262            rho_coefficient = z_i.alpha * velocity(z_i.theta, z_j.phi)
263            sigma_coefficient = z_j.beta * velocity(z_j.phi, z_i.theta)
264            ctrl_pt_a = z_i + (1 / 3) * rho_coefficient * cmath.exp(
265                complex(0, z_i.theta)
266            ) * (z_j - z_i)
267            ctrl_pt_b = z_j - (1 / 3) * sigma_coefficient * cmath.exp(
268                complex(0, -z_j.phi)
269            ) * (z_j - z_i)
270            ctrl_pts.append((ctrl_pt_a.real, ctrl_pt_a.imag))
271            ctrl_pts.append((ctrl_pt_b.real, ctrl_pt_b.imag))
272        return ctrl_pts
273
274    def show_debug_msg(self) -> None:
275        """Display debug information for each point if debug mode is enabled."""
276        if self.debug_mode:
277            for point in self.points:
278                print(point.debug_info())
279
280    def __repr__(self) -> str:
281        """Return a string representation of the curve.
282
283        Returns:
284            A string representation of the curve's points in Cartesian coordinates.
285        """
286        cartesian_points = [(point.real, point.imag) for point in self.points]
287        return repr(cartesian_points)
288
289
290def hobby_ctrl_points(
291    points: list[tuple],
292    tension: float = 1,
293    cyclic: bool = False,
294    begin_curl: float = 1,
295    end_curl: float = 1,
296    debug: bool = False,
297) -> list[tuple]:
298    """Calculate cubic Bezier control points using John Hobby's algorithm.
299
300    Args:
301        points: List of (x, y) tuples representing the curve's points.
302        tension: Controls the "tightness" of the curve (lower is tighter).
303        cyclic: Whether the curve should be closed.
304        begin_curl: Curl value for the beginning of the curve.
305        end_curl: Curl value for the end of the curve.
306        debug: Whether to print debug information.
307
308    Returns:
309        A list of (x, y) tuples representing the Bezier control points.
310    """
311    curve = HobbyCurve(
312        points,
313        tension=tension,
314        cyclic=cyclic,
315        begin_curl=begin_curl,
316        end_curl=end_curl,
317        debug=debug,
318    )
319    ctrl_points = curve.get_ctrl_points()
320
321    # Calculate whitespace padding for pretty print.
322    max_pad = 0
323    for ctrl_point in ctrl_points:
324        x, y = ctrl_point[:2]
325        # Calculate number of digits in x, y before decimal, and take the
326        # max for nice padding.
327        padding = max(
328            1 if abs(x) <= 0.1 else int(np.ceil(np.log10(abs(x)))) + 1,
329            1 if abs(y) <= 0.1 else int(np.ceil(np.log10(abs(y)))) + 1,
330        )
331        if max_pad < padding:
332            max_pad = padding
333
334    if debug:
335        # Pretty print control points.
336        precision = 10
337        space = precision + max_pad + 1  # +1 for negative sign
338        i = 0
339        while i < len(ctrl_points) - 1:
340            x_1, y_1 = ctrl_points[i]
341            x_2, y_2 = ctrl_points[i + 1]
342            print(
343                f"({x_1:<{space}.{precision}f}, {y_1:<{space}.{precision}f}) "
344                f"and "
345                f"({x_2:<{space}.{precision}f}, {y_2:<{space}.{precision}f})"
346            )
347            i += 2
348
349    return ctrl_points
350
351
352def velocity(theta: float, phi: float) -> float:
353    """Calculate the "velocity" function used in Metafont's curve algorithm.
354
355    This function implements the specific velocity formula from Knuth's Metafont.
356
357    Args:
358        theta: The theta angle value.
359        phi: The phi angle value.
360
361    Returns:
362        The computed velocity value used in control point calculations.
363    """
364    numerator = 2 + np.sqrt(2) * (np.sin(theta) - (1 / 16) * np.sin(phi)) * (
365        np.sin(phi) - (1 / 16) * np.sin(theta)
366    ) * (np.cos(theta) - np.cos(phi))
367    denominator = (
368        1
369        + (1 / 2) * (np.sqrt(5) - 1) * np.cos(theta)
370        + (1 / 2) * (3 - np.sqrt(5)) * np.cos(phi)
371    )
372    return numerator / denominator
373
374def hobby_shape(points, cyclic=False, tension=1, begin_curl=1, end_curl=1, n_points=None):
375    """Create a Shape object from points using John Hobby's algorithm.
376
377    This function calculates cubic Bezier control points using Hobby's algorithm,
378    then creates a Shape object by generating points along the resulting Bezier curves.
379
380    Args:
381        points: List of (x, y) tuples representing the curve's points.
382        cyclic: Whether the curve should be closed.
383        tension: Controls the "tightness" of the curve (lower is tighter).
384        begin_curl: Curl value for the beginning of the curve.
385        end_curl: Curl value for the end of the curve.
386        debug: Whether to print debug information.
387
388    Returns:
389        A Shape object containing points along the smooth Hobby curve.
390    """
391    controls = hobby_ctrl_points(points, tension=tension, cyclic=cyclic,
392                                            begin_curl=begin_curl, end_curl=end_curl)
393    n = len(points)
394    res = []
395    if cyclic:
396        for i in range(n):
397            ind = i * 2
398            p0 = points[i]
399            p1 = controls[ind]
400            p2 = controls[ind + 1]
401            p3 = points[(i + 1)%n]
402            bez_pnts = bezier_points(p0, p1, p2, p3, 10)
403            res.extend(bez_pnts)
404    else:
405        for i in range(n - 1):
406            ind = i * 2
407            p0 = points[i]
408            p1 = controls[ind]
409            p2 = controls[ind + 1]
410            p3 = points[(i + 1)]
411            bez_pnts = bezier_points(p0, p1, p2, p3, 20)
412            res.extend(bez_pnts)
413    return Shape(res)
class HobbyPoint(builtins.complex):
15class HobbyPoint(complex):
16    """A class for associating numerical quantities from Hobby's algorithm with points that appear on a Hobby curve.
17
18    We subclass `complex` to perform complex arithmetic with points on the Hobby curve, as required in the algorithm.
19
20    Attributes:
21        x (float): The x-coordinate of the point.
22        y (float): The y-coordinate of the point.
23        alpha (float): The reciprocal of tension for incoming segment.
24        beta (float): The reciprocal of tension for outgoing segment.
25        d_val (float): Distance between this point and next.
26        theta (float): Angle of polygonal line from this point to next.
27        phi (float): Offset angle.
28        psi (float): Another offset angle.
29    """
30
31    def __new__(cls, x: float, y: float, tension: float) -> "HobbyPoint":
32        """Create a new instance of HobbyPoint.
33
34        Args:
35            x: The x-coordinate of the point.
36            y: The y-coordinate of the point.
37            tension: The tension value for the curve.
38
39        Returns:
40            A new HobbyPoint instance with complex value (x + y*j).
41        """
42        return super().__new__(cls, x, y)
43
44    def __init__(self, x: float, y: float, tension: float) -> None:
45        """Initialize a HobbyPoint with coordinates and tension.
46
47        Args:
48            x: The x-coordinate of the point.
49            y: The y-coordinate of the point.
50            tension: The tension value for the curve.
51        """
52        self.x = x
53        self.y = y
54        # In what follows, we use Knuth's notation in our variable names.
55        self.alpha = 1 / tension
56        self.beta = 1 / tension
57        self.d_val = 0  # Distance between this point and next.
58        self.theta = 0  # Angle of polygonal line from this point to next.
59        self.phi = 0  # Offset angle.
60        self.psi = 0  # Another offset angle.
61
62    def debug_info(self) -> str:
63        """Return a string with the point's information.
64
65        Returns:
66            A string containing the point's coordinates and all of its computational values.
67        """
68        return (
69            f"{(self.x, self.y)} "
70            f"alpha={self.alpha}, "
71            f"beta={self.beta}, "
72            f"theta={self.theta}, "
73            f"psi={self.psi}, "
74            f"phi={self.phi}, "
75            f"d_val={self.d_val}"
76        )
77
78    def __repr__(self) -> str:
79        """Return a string representation of the point.
80
81        Returns:
82            A string representation of the point's coordinates.
83        """
84        return f"{(self.x, self.y)}"

A class for associating numerical quantities from Hobby's algorithm with points that appear on a Hobby curve.

We subclass complex to perform complex arithmetic with points on the Hobby curve, as required in the algorithm.

Attributes:
  • x (float): The x-coordinate of the point.
  • y (float): The y-coordinate of the point.
  • alpha (float): The reciprocal of tension for incoming segment.
  • beta (float): The reciprocal of tension for outgoing segment.
  • d_val (float): Distance between this point and next.
  • theta (float): Angle of polygonal line from this point to next.
  • phi (float): Offset angle.
  • psi (float): Another offset angle.
HobbyPoint(x: float, y: float, tension: float)
44    def __init__(self, x: float, y: float, tension: float) -> None:
45        """Initialize a HobbyPoint with coordinates and tension.
46
47        Args:
48            x: The x-coordinate of the point.
49            y: The y-coordinate of the point.
50            tension: The tension value for the curve.
51        """
52        self.x = x
53        self.y = y
54        # In what follows, we use Knuth's notation in our variable names.
55        self.alpha = 1 / tension
56        self.beta = 1 / tension
57        self.d_val = 0  # Distance between this point and next.
58        self.theta = 0  # Angle of polygonal line from this point to next.
59        self.phi = 0  # Offset angle.
60        self.psi = 0  # Another offset angle.

Initialize a HobbyPoint with coordinates and tension.

Arguments:
  • x: The x-coordinate of the point.
  • y: The y-coordinate of the point.
  • tension: The tension value for the curve.
x
y
alpha
beta
d_val
theta
phi
psi
def debug_info(self) -> str:
62    def debug_info(self) -> str:
63        """Return a string with the point's information.
64
65        Returns:
66            A string containing the point's coordinates and all of its computational values.
67        """
68        return (
69            f"{(self.x, self.y)} "
70            f"alpha={self.alpha}, "
71            f"beta={self.beta}, "
72            f"theta={self.theta}, "
73            f"psi={self.psi}, "
74            f"phi={self.phi}, "
75            f"d_val={self.d_val}"
76        )

Return a string with the point's information.

Returns:

A string containing the point's coordinates and all of its computational values.

class HobbyCurve:
 87class HobbyCurve:
 88    """A class for calculating the control points required to draw a Hobby curve.
 89
 90    Attributes:
 91        points (list[HobbyPoint]): The list of points defining the curve.
 92        ctrl_pts (list[tuple]): The calculated control points.
 93        is_cyclic (bool): Whether the curve is closed.
 94        begin_curl (float): Curl value for the beginning of the curve.
 95        end_curl (float): Curl value for the end of the curve.
 96        n_points (int): Number of points in the curve.
 97        debug_mode (bool): Whether to print debug information.
 98    """
 99
100    def __init__(
101        self,
102        points: list[tuple],
103        tension: float = 1,
104        cyclic: bool = False,
105        begin_curl: float = 1,
106        end_curl: float = 1,
107        debug: bool = False,
108    ) -> None:
109        """Initialize a HobbyCurve with the given parameters.
110
111        Args:
112            points: List of (x, y) tuples representing the curve's points.
113            tension: Tension parameter controlling the "tightness" of the curve.
114            cyclic: Whether the curve should be closed.
115            begin_curl: Curl value for the beginning of the curve.
116            end_curl: Curl value for the end of the curve.
117            debug: Whether to print debug information.
118
119        Raises:
120            ValueError: If fewer than 2 points are provided.
121        """
122        if len(points) < 2:
123            raise ValueError("Algorithm needs more than 2 points")
124        self.points = [HobbyPoint(*point, tension) for point in points]
125        self.ctrl_pts = []
126        self.is_cyclic = cyclic
127        self.begin_curl = begin_curl
128        self.end_curl = end_curl
129        self.n_points = len(points)
130        self.debug_mode = debug
131
132    def get_ctrl_points(self) -> list[tuple]:
133        """Calculate and return all of the control points of the Hobby curve.
134
135        Executes the Hobby algorithm by calculating distance values, psi values,
136        theta values, and phi values, then uses these to compute control points.
137
138        Returns:
139            A list of (x, y) tuples representing the Bezier control points.
140        """
141        self.calculate_d_vals()
142        self.calculate_psi_vals()
143        self.calculate_theta_vals()
144        self.calculate_phi_vals()
145        self.show_debug_msg()
146        self.ctrl_pts = self.calculate_ctrl_pts()
147        return self.ctrl_pts
148
149    def calculate_d_vals(self) -> None:
150        """Calculate the pairwise distances between consecutive points in the curve."""
151        # Skip last point if path is non-cyclic
152        point_inds = (
153            range(self.n_points) if self.is_cyclic else range(self.n_points - 1)
154        )
155        for i in point_inds:
156            z_i = self.points[i % self.n_points]
157            z_j = self.points[(i + 1) % self.n_points]
158            z_i.d_val = abs(z_i - z_j)
159
160    def calculate_psi_vals(self) -> None:
161        """Calculate the psi values by finding the angle of the polygonal turns.
162
163        Raises:
164            ZeroDivisionError: If consecutive points have the same coordinates.
165        """
166        # Skip first and last point if path is non-cyclic
167        point_inds = (
168            range(self.n_points) if self.is_cyclic else range(1, self.n_points - 1)
169        )
170        for i in point_inds:
171            z_h = self.points[i - 1]
172            z_i = self.points[i]
173            z_j = self.points[(i + 1) % self.n_points]
174            try:
175                polygonal_turn = (z_j - z_i) / (z_i - z_h)
176                # print(z_j - z_i, z_i - z_h)
177            except ZeroDivisionError:
178                raise ZeroDivisionError(
179                    f"Consecutive points {(z_h.x, z_h.y)} and {(z_i.x, z_i.y)} cause zero division."
180                )
181            z_i.psi = np.arctan2(polygonal_turn.imag, polygonal_turn.real)
182
183    def calculate_theta_vals(self) -> None:
184        """Calculate the theta values by solving a linear system of equations.
185
186        This is the core of Hobby's algorithm, creating and solving a system of equations
187        to find the optimal angles for smooth splines.
188        """
189        A = np.zeros(
190            self.n_points
191        )  # Inappropriate names, but they mirror Knuth's notation.
192        B = np.zeros(self.n_points)
193        C = np.zeros(self.n_points)
194        D = np.zeros(self.n_points)
195        R = np.zeros(self.n_points)
196
197        # Calculate the entries of the five vectors.
198        # Skip first and last point if path is non-cyclic.
199        point_ind = (
200            range(self.n_points) if self.is_cyclic else range(1, self.n_points - 1)
201        )
202        for i in point_ind:
203            z_h = self.points[i - 1]
204            z_i = self.points[i]
205            z_j = self.points[(i + 1) % self.n_points]
206
207            A[i] = z_h.alpha / (z_i.beta**2 * z_h.d_val)
208            B[i] = (3 - z_h.alpha) / (z_i.beta**2 * z_h.d_val)
209            C[i] = (3 - z_j.beta) / (z_i.alpha**2 * z_i.d_val)
210            D[i] = z_j.beta / (z_i.alpha**2 * z_i.d_val)
211            R[i] = -B[i] * z_i.psi - D[i] * z_j.psi
212
213        # Set up matrix M such that the soln. Mx = R are the theta values.
214        M = np.zeros((self.n_points, self.n_points))
215        for i in range(self.n_points):
216            # Fill i-th row of M
217            M[i][i - 1] = A[i]
218            M[i][i] = B[i] + C[i]
219            M[i][(i + 1) % self.n_points] = D[i]
220
221        # Special formulas for first and last rows of M with non-cyclic paths.
222        if not self.is_cyclic:
223            # First row of M
224            alpha_0 = self.points[0].alpha
225            beta_1 = self.points[1].beta
226            xi_0 = (alpha_0**2 * self.begin_curl) / beta_1**2
227            M[0][0] = alpha_0 * xi_0 + 3 - beta_1
228            M[0][1] = (3 - alpha_0) * xi_0 + beta_1
229            R[0] = -((3 - alpha_0) * xi_0 + beta_1) * self.points[1].psi
230            # Last row of M
231            alpha_n_1 = self.points[-2].alpha
232            beta_n = self.points[-1].beta
233            xi_n = (beta_n**2 * self.end_curl) / alpha_n_1**2
234            M[-1][-2] = (3 - beta_n) * xi_n + alpha_n_1
235            M[-1][-1] = beta_n * xi_n + 3 - alpha_n_1
236            R[-1] = 0
237
238        # Solve for theta values.
239        thetas = np.linalg.solve(M, R)
240        for i, point in enumerate(self.points):
241            point.theta = thetas[i]
242
243    def calculate_phi_vals(self) -> None:
244        """Calculate the phi values using the relationship theta + phi + psi = 0."""
245        for point in self.points:
246            point.phi = -(point.psi + point.theta)
247
248    def calculate_ctrl_pts(self) -> list[tuple]:
249        """Calculate the Bezier control points between consecutive points.
250
251        Returns:
252            A list of (x, y) tuples representing the control points, with two control points
253            for each curve segment between consecutive points.
254        """
255        ctrl_pts = []
256        # Skip last point if path is non-cyclic
257        point_inds = (
258            range(self.n_points) if self.is_cyclic else range(self.n_points - 1)
259        )
260        for i in point_inds:
261            z_i = self.points[i]
262            z_j = self.points[(i + 1) % self.n_points]
263            rho_coefficient = z_i.alpha * velocity(z_i.theta, z_j.phi)
264            sigma_coefficient = z_j.beta * velocity(z_j.phi, z_i.theta)
265            ctrl_pt_a = z_i + (1 / 3) * rho_coefficient * cmath.exp(
266                complex(0, z_i.theta)
267            ) * (z_j - z_i)
268            ctrl_pt_b = z_j - (1 / 3) * sigma_coefficient * cmath.exp(
269                complex(0, -z_j.phi)
270            ) * (z_j - z_i)
271            ctrl_pts.append((ctrl_pt_a.real, ctrl_pt_a.imag))
272            ctrl_pts.append((ctrl_pt_b.real, ctrl_pt_b.imag))
273        return ctrl_pts
274
275    def show_debug_msg(self) -> None:
276        """Display debug information for each point if debug mode is enabled."""
277        if self.debug_mode:
278            for point in self.points:
279                print(point.debug_info())
280
281    def __repr__(self) -> str:
282        """Return a string representation of the curve.
283
284        Returns:
285            A string representation of the curve's points in Cartesian coordinates.
286        """
287        cartesian_points = [(point.real, point.imag) for point in self.points]
288        return repr(cartesian_points)

A class for calculating the control points required to draw a Hobby curve.

Attributes:
  • points (list[HobbyPoint]): The list of points defining the curve.
  • ctrl_pts (list[tuple]): The calculated control points.
  • is_cyclic (bool): Whether the curve is closed.
  • begin_curl (float): Curl value for the beginning of the curve.
  • end_curl (float): Curl value for the end of the curve.
  • n_points (int): Number of points in the curve.
  • debug_mode (bool): Whether to print debug information.
HobbyCurve( points: list[tuple], tension: float = 1, cyclic: bool = False, begin_curl: float = 1, end_curl: float = 1, debug: bool = False)
100    def __init__(
101        self,
102        points: list[tuple],
103        tension: float = 1,
104        cyclic: bool = False,
105        begin_curl: float = 1,
106        end_curl: float = 1,
107        debug: bool = False,
108    ) -> None:
109        """Initialize a HobbyCurve with the given parameters.
110
111        Args:
112            points: List of (x, y) tuples representing the curve's points.
113            tension: Tension parameter controlling the "tightness" of the curve.
114            cyclic: Whether the curve should be closed.
115            begin_curl: Curl value for the beginning of the curve.
116            end_curl: Curl value for the end of the curve.
117            debug: Whether to print debug information.
118
119        Raises:
120            ValueError: If fewer than 2 points are provided.
121        """
122        if len(points) < 2:
123            raise ValueError("Algorithm needs more than 2 points")
124        self.points = [HobbyPoint(*point, tension) for point in points]
125        self.ctrl_pts = []
126        self.is_cyclic = cyclic
127        self.begin_curl = begin_curl
128        self.end_curl = end_curl
129        self.n_points = len(points)
130        self.debug_mode = debug

Initialize a HobbyCurve with the given parameters.

Arguments:
  • points: List of (x, y) tuples representing the curve's points.
  • tension: Tension parameter controlling the "tightness" of the curve.
  • cyclic: Whether the curve should be closed.
  • begin_curl: Curl value for the beginning of the curve.
  • end_curl: Curl value for the end of the curve.
  • debug: Whether to print debug information.
Raises:
  • ValueError: If fewer than 2 points are provided.
points
ctrl_pts
is_cyclic
begin_curl
end_curl
n_points
debug_mode
def get_ctrl_points(self) -> list[tuple]:
132    def get_ctrl_points(self) -> list[tuple]:
133        """Calculate and return all of the control points of the Hobby curve.
134
135        Executes the Hobby algorithm by calculating distance values, psi values,
136        theta values, and phi values, then uses these to compute control points.
137
138        Returns:
139            A list of (x, y) tuples representing the Bezier control points.
140        """
141        self.calculate_d_vals()
142        self.calculate_psi_vals()
143        self.calculate_theta_vals()
144        self.calculate_phi_vals()
145        self.show_debug_msg()
146        self.ctrl_pts = self.calculate_ctrl_pts()
147        return self.ctrl_pts

Calculate and return all of the control points of the Hobby curve.

Executes the Hobby algorithm by calculating distance values, psi values, theta values, and phi values, then uses these to compute control points.

Returns:

A list of (x, y) tuples representing the Bezier control points.

def calculate_d_vals(self) -> None:
149    def calculate_d_vals(self) -> None:
150        """Calculate the pairwise distances between consecutive points in the curve."""
151        # Skip last point if path is non-cyclic
152        point_inds = (
153            range(self.n_points) if self.is_cyclic else range(self.n_points - 1)
154        )
155        for i in point_inds:
156            z_i = self.points[i % self.n_points]
157            z_j = self.points[(i + 1) % self.n_points]
158            z_i.d_val = abs(z_i - z_j)

Calculate the pairwise distances between consecutive points in the curve.

def calculate_psi_vals(self) -> None:
160    def calculate_psi_vals(self) -> None:
161        """Calculate the psi values by finding the angle of the polygonal turns.
162
163        Raises:
164            ZeroDivisionError: If consecutive points have the same coordinates.
165        """
166        # Skip first and last point if path is non-cyclic
167        point_inds = (
168            range(self.n_points) if self.is_cyclic else range(1, self.n_points - 1)
169        )
170        for i in point_inds:
171            z_h = self.points[i - 1]
172            z_i = self.points[i]
173            z_j = self.points[(i + 1) % self.n_points]
174            try:
175                polygonal_turn = (z_j - z_i) / (z_i - z_h)
176                # print(z_j - z_i, z_i - z_h)
177            except ZeroDivisionError:
178                raise ZeroDivisionError(
179                    f"Consecutive points {(z_h.x, z_h.y)} and {(z_i.x, z_i.y)} cause zero division."
180                )
181            z_i.psi = np.arctan2(polygonal_turn.imag, polygonal_turn.real)

Calculate the psi values by finding the angle of the polygonal turns.

Raises:
  • ZeroDivisionError: If consecutive points have the same coordinates.
def calculate_theta_vals(self) -> None:
183    def calculate_theta_vals(self) -> None:
184        """Calculate the theta values by solving a linear system of equations.
185
186        This is the core of Hobby's algorithm, creating and solving a system of equations
187        to find the optimal angles for smooth splines.
188        """
189        A = np.zeros(
190            self.n_points
191        )  # Inappropriate names, but they mirror Knuth's notation.
192        B = np.zeros(self.n_points)
193        C = np.zeros(self.n_points)
194        D = np.zeros(self.n_points)
195        R = np.zeros(self.n_points)
196
197        # Calculate the entries of the five vectors.
198        # Skip first and last point if path is non-cyclic.
199        point_ind = (
200            range(self.n_points) if self.is_cyclic else range(1, self.n_points - 1)
201        )
202        for i in point_ind:
203            z_h = self.points[i - 1]
204            z_i = self.points[i]
205            z_j = self.points[(i + 1) % self.n_points]
206
207            A[i] = z_h.alpha / (z_i.beta**2 * z_h.d_val)
208            B[i] = (3 - z_h.alpha) / (z_i.beta**2 * z_h.d_val)
209            C[i] = (3 - z_j.beta) / (z_i.alpha**2 * z_i.d_val)
210            D[i] = z_j.beta / (z_i.alpha**2 * z_i.d_val)
211            R[i] = -B[i] * z_i.psi - D[i] * z_j.psi
212
213        # Set up matrix M such that the soln. Mx = R are the theta values.
214        M = np.zeros((self.n_points, self.n_points))
215        for i in range(self.n_points):
216            # Fill i-th row of M
217            M[i][i - 1] = A[i]
218            M[i][i] = B[i] + C[i]
219            M[i][(i + 1) % self.n_points] = D[i]
220
221        # Special formulas for first and last rows of M with non-cyclic paths.
222        if not self.is_cyclic:
223            # First row of M
224            alpha_0 = self.points[0].alpha
225            beta_1 = self.points[1].beta
226            xi_0 = (alpha_0**2 * self.begin_curl) / beta_1**2
227            M[0][0] = alpha_0 * xi_0 + 3 - beta_1
228            M[0][1] = (3 - alpha_0) * xi_0 + beta_1
229            R[0] = -((3 - alpha_0) * xi_0 + beta_1) * self.points[1].psi
230            # Last row of M
231            alpha_n_1 = self.points[-2].alpha
232            beta_n = self.points[-1].beta
233            xi_n = (beta_n**2 * self.end_curl) / alpha_n_1**2
234            M[-1][-2] = (3 - beta_n) * xi_n + alpha_n_1
235            M[-1][-1] = beta_n * xi_n + 3 - alpha_n_1
236            R[-1] = 0
237
238        # Solve for theta values.
239        thetas = np.linalg.solve(M, R)
240        for i, point in enumerate(self.points):
241            point.theta = thetas[i]

Calculate the theta values by solving a linear system of equations.

This is the core of Hobby's algorithm, creating and solving a system of equations to find the optimal angles for smooth splines.

def calculate_phi_vals(self) -> None:
243    def calculate_phi_vals(self) -> None:
244        """Calculate the phi values using the relationship theta + phi + psi = 0."""
245        for point in self.points:
246            point.phi = -(point.psi + point.theta)

Calculate the phi values using the relationship theta + phi + psi = 0.

def calculate_ctrl_pts(self) -> list[tuple]:
248    def calculate_ctrl_pts(self) -> list[tuple]:
249        """Calculate the Bezier control points between consecutive points.
250
251        Returns:
252            A list of (x, y) tuples representing the control points, with two control points
253            for each curve segment between consecutive points.
254        """
255        ctrl_pts = []
256        # Skip last point if path is non-cyclic
257        point_inds = (
258            range(self.n_points) if self.is_cyclic else range(self.n_points - 1)
259        )
260        for i in point_inds:
261            z_i = self.points[i]
262            z_j = self.points[(i + 1) % self.n_points]
263            rho_coefficient = z_i.alpha * velocity(z_i.theta, z_j.phi)
264            sigma_coefficient = z_j.beta * velocity(z_j.phi, z_i.theta)
265            ctrl_pt_a = z_i + (1 / 3) * rho_coefficient * cmath.exp(
266                complex(0, z_i.theta)
267            ) * (z_j - z_i)
268            ctrl_pt_b = z_j - (1 / 3) * sigma_coefficient * cmath.exp(
269                complex(0, -z_j.phi)
270            ) * (z_j - z_i)
271            ctrl_pts.append((ctrl_pt_a.real, ctrl_pt_a.imag))
272            ctrl_pts.append((ctrl_pt_b.real, ctrl_pt_b.imag))
273        return ctrl_pts

Calculate the Bezier control points between consecutive points.

Returns:

A list of (x, y) tuples representing the control points, with two control points for each curve segment between consecutive points.

def show_debug_msg(self) -> None:
275    def show_debug_msg(self) -> None:
276        """Display debug information for each point if debug mode is enabled."""
277        if self.debug_mode:
278            for point in self.points:
279                print(point.debug_info())

Display debug information for each point if debug mode is enabled.

def hobby_ctrl_points( points: list[tuple], tension: float = 1, cyclic: bool = False, begin_curl: float = 1, end_curl: float = 1, debug: bool = False) -> list[tuple]:
291def hobby_ctrl_points(
292    points: list[tuple],
293    tension: float = 1,
294    cyclic: bool = False,
295    begin_curl: float = 1,
296    end_curl: float = 1,
297    debug: bool = False,
298) -> list[tuple]:
299    """Calculate cubic Bezier control points using John Hobby's algorithm.
300
301    Args:
302        points: List of (x, y) tuples representing the curve's points.
303        tension: Controls the "tightness" of the curve (lower is tighter).
304        cyclic: Whether the curve should be closed.
305        begin_curl: Curl value for the beginning of the curve.
306        end_curl: Curl value for the end of the curve.
307        debug: Whether to print debug information.
308
309    Returns:
310        A list of (x, y) tuples representing the Bezier control points.
311    """
312    curve = HobbyCurve(
313        points,
314        tension=tension,
315        cyclic=cyclic,
316        begin_curl=begin_curl,
317        end_curl=end_curl,
318        debug=debug,
319    )
320    ctrl_points = curve.get_ctrl_points()
321
322    # Calculate whitespace padding for pretty print.
323    max_pad = 0
324    for ctrl_point in ctrl_points:
325        x, y = ctrl_point[:2]
326        # Calculate number of digits in x, y before decimal, and take the
327        # max for nice padding.
328        padding = max(
329            1 if abs(x) <= 0.1 else int(np.ceil(np.log10(abs(x)))) + 1,
330            1 if abs(y) <= 0.1 else int(np.ceil(np.log10(abs(y)))) + 1,
331        )
332        if max_pad < padding:
333            max_pad = padding
334
335    if debug:
336        # Pretty print control points.
337        precision = 10
338        space = precision + max_pad + 1  # +1 for negative sign
339        i = 0
340        while i < len(ctrl_points) - 1:
341            x_1, y_1 = ctrl_points[i]
342            x_2, y_2 = ctrl_points[i + 1]
343            print(
344                f"({x_1:<{space}.{precision}f}, {y_1:<{space}.{precision}f}) "
345                f"and "
346                f"({x_2:<{space}.{precision}f}, {y_2:<{space}.{precision}f})"
347            )
348            i += 2
349
350    return ctrl_points

Calculate cubic Bezier control points using John Hobby's algorithm.

Arguments:
  • points: List of (x, y) tuples representing the curve's points.
  • tension: Controls the "tightness" of the curve (lower is tighter).
  • cyclic: Whether the curve should be closed.
  • begin_curl: Curl value for the beginning of the curve.
  • end_curl: Curl value for the end of the curve.
  • debug: Whether to print debug information.
Returns:

A list of (x, y) tuples representing the Bezier control points.

def velocity(theta: float, phi: float) -> float:
353def velocity(theta: float, phi: float) -> float:
354    """Calculate the "velocity" function used in Metafont's curve algorithm.
355
356    This function implements the specific velocity formula from Knuth's Metafont.
357
358    Args:
359        theta: The theta angle value.
360        phi: The phi angle value.
361
362    Returns:
363        The computed velocity value used in control point calculations.
364    """
365    numerator = 2 + np.sqrt(2) * (np.sin(theta) - (1 / 16) * np.sin(phi)) * (
366        np.sin(phi) - (1 / 16) * np.sin(theta)
367    ) * (np.cos(theta) - np.cos(phi))
368    denominator = (
369        1
370        + (1 / 2) * (np.sqrt(5) - 1) * np.cos(theta)
371        + (1 / 2) * (3 - np.sqrt(5)) * np.cos(phi)
372    )
373    return numerator / denominator

Calculate the "velocity" function used in Metafont's curve algorithm.

This function implements the specific velocity formula from Knuth's Metafont.

Arguments:
  • theta: The theta angle value.
  • phi: The phi angle value.
Returns:

The computed velocity value used in control point calculations.

def hobby_shape( points, cyclic=False, tension=1, begin_curl=1, end_curl=1, n_points=None):
375def hobby_shape(points, cyclic=False, tension=1, begin_curl=1, end_curl=1, n_points=None):
376    """Create a Shape object from points using John Hobby's algorithm.
377
378    This function calculates cubic Bezier control points using Hobby's algorithm,
379    then creates a Shape object by generating points along the resulting Bezier curves.
380
381    Args:
382        points: List of (x, y) tuples representing the curve's points.
383        cyclic: Whether the curve should be closed.
384        tension: Controls the "tightness" of the curve (lower is tighter).
385        begin_curl: Curl value for the beginning of the curve.
386        end_curl: Curl value for the end of the curve.
387        debug: Whether to print debug information.
388
389    Returns:
390        A Shape object containing points along the smooth Hobby curve.
391    """
392    controls = hobby_ctrl_points(points, tension=tension, cyclic=cyclic,
393                                            begin_curl=begin_curl, end_curl=end_curl)
394    n = len(points)
395    res = []
396    if cyclic:
397        for i in range(n):
398            ind = i * 2
399            p0 = points[i]
400            p1 = controls[ind]
401            p2 = controls[ind + 1]
402            p3 = points[(i + 1)%n]
403            bez_pnts = bezier_points(p0, p1, p2, p3, 10)
404            res.extend(bez_pnts)
405    else:
406        for i in range(n - 1):
407            ind = i * 2
408            p0 = points[i]
409            p1 = controls[ind]
410            p2 = controls[ind + 1]
411            p3 = points[(i + 1)]
412            bez_pnts = bezier_points(p0, p1, p2, p3, 20)
413            res.extend(bez_pnts)
414    return Shape(res)

Create a Shape object from points using John Hobby's algorithm.

This function calculates cubic Bezier control points using Hobby's algorithm, then creates a Shape object by generating points along the resulting Bezier curves.

Arguments:
  • points: List of (x, y) tuples representing the curve's points.
  • cyclic: Whether the curve should be closed.
  • tension: Controls the "tightness" of the curve (lower is tighter).
  • begin_curl: Curl value for the beginning of the curve.
  • end_curl: Curl value for the end of the curve.
  • debug: Whether to print debug information.
Returns:

A Shape object containing points along the smooth Hobby curve.