diff --git a/pyonfx/shape.py b/pyonfx/shape.py index cff9019c..aae95557 100644 --- a/pyonfx/shape.py +++ b/pyonfx/shape.py @@ -233,23 +233,33 @@ def map( self.drawing_cmds = " ".join(cmds_and_points) return self - def bounding(self) -> Tuple[float, float, float, float]: - """Calculates shape bounding box. + def bounding(self, exact: bool = True) -> Tuple[float, float, float, float]: + """Calculates the shape's bounding box. **Tips:** *Using this you can get more precise information about a shape (width, height, position).* + Parameters: + exact (bool): Whether the calculation of the bounding box should be exact, which is more precise for bezier curves. + Returns: A tuple (x0, y0, x1, y1) containing coordinates of the bounding box. Examples: .. code-block:: python3 - print("Left-top: %d %d\\nRight-bottom: %d %d" % ( Shape("m 10 5 l 25 5 25 42 10 42").bounding() ) ) + print( "Left-top: %d %d\\nRight-bottom: %d %d" % ( Shape("m 10 5 l 25 5 25 42 10 42").bounding() ) ) + print( Shape("m 313 312 b 254 287 482 38 277 212 l 436 269 b 378 388 461 671 260 481").bounding() ) + print( Shape("m 313 312 b 254 287 482 38 277 212 l 436 269 b 378 388 461 671 260 481").bounding(exact=False) ) >>> Left-top: 10 5 >>> Right-bottom: 25 42 + >>> (260.0, 150.67823683425252, 436.0, 544.871772934194) + >>> (254.0, 38.0, 482.0, 671.0) """ + if exact: + return self.__bounding_exact() + # Bounding data x0: float = None y0: float = None @@ -268,11 +278,129 @@ def compute_edges(x, y): self.map(compute_edges) return x0, y0, x1, y1 - def move(self, x: float = None, y: float = None) -> Shape: + def __bounding_exact(self) -> Tuple[float, float, float, float]: + # From: https://stackoverflow.com/a/14429749 + def get_bounds_of_curve(x0, y0, x1, y1, x2, y2, x3, y3): + tvalues = [] + + for i in range(2): + if i == 0: + b = 6 * x0 - 12 * x1 + 6 * x2 + a = -3 * x0 + 9 * x1 - 9 * x2 + 3 * x3 + c = 3 * x1 - 3 * x0 + else: + b = 6 * y0 - 12 * y1 + 6 * y2 + a = -3 * y0 + 9 * y1 - 9 * y2 + 3 * y3 + c = 3 * y1 - 3 * y0 + + if abs(a) < 1e-12: # Numerical robustness + if abs(b) < 1e-12: # Numerical robustness + continue + t = -c / b + if 0 < t < 1: + tvalues.append(t) + continue + b2ac = b * b - 4 * c * a + if b2ac < 0: + continue + sqrtb2ac = math.sqrt(b2ac) + t1 = (-b + sqrtb2ac) / (2 * a) + if 0 < t1 < 1: + tvalues.append(t1) + t2 = (-b - sqrtb2ac) / (2 * a) + if 0 < t2 < 1: + tvalues.append(t2) + + x_min, y_min, x_max, y_max = math.inf, math.inf, -math.inf, -math.inf + + for t in tvalues: + mt = 1 - t + x = ( + (mt * mt * mt * x0) + + (3 * mt * mt * t * x1) + + (3 * mt * t * t * x2) + + (t * t * t * x3) + ) + x_min, x_max = min(x_min, x), max(x_max, x) + y = ( + (mt * mt * mt * y0) + + (3 * mt * mt * t * y1) + + (3 * mt * t * t * y2) + + (t * t * t * y3) + ) + y_min, y_max = min(y_min, y), max(y_max, y) + + x_min, x_max = min(x_min, x0), max(x_max, x0) + y_min, y_max = min(y_min, y0), max(y_max, y0) + x_min, x_max = min(x_min, x3), max(x_max, x3) + y_min, y_max = min(y_min, y3), max(y_max, y3) + + if math.inf in (x_min, y_min) or -math.inf in (x_max, y_max): + raise ValueError("Invalid bezier curve") + + return x_min, y_min, x_max, y_max + + x_min, y_min, x_max, y_max = math.inf, math.inf, -math.inf, -math.inf + + def update_min_max(x, y): + nonlocal x_min, y_min, x_max, y_max + x_min = min(x_min, x) + y_min = min(y_min, y) + x_max = max(x_max, x) + y_max = max(y_max, y) + + instructions = self.drawing_cmds.split() + curr_identifier, curr_values = None, [] + cursor = (0, 0) + + for instruction in instructions: + is_identifier = instruction.isalpha() + + if is_identifier: + if curr_values: + raise ValueError("Unexpected end of the shape") + curr_identifier = instruction + continue + + if curr_identifier is None: + raise ValueError("No shape instruction found") + + curr_values.append(float(instruction)) + + if curr_identifier == "m": + if len(curr_values) == 2: + cursor = tuple(curr_values) + curr_values = [] + elif curr_identifier == "l": + if len(curr_values) == 2: + update_min_max(*cursor) + cursor = tuple(curr_values) + update_min_max(*cursor) + curr_values = [] + elif curr_identifier == "b": + if len(curr_values) == 6: + bounds = get_bounds_of_curve(*cursor, *curr_values) + update_min_max(*bounds[:2]) + update_min_max(*bounds[2:]) + cursor = tuple(curr_values[-2:]) + curr_values = [] + else: + raise NotImplementedError( + f"Drawing command '{curr_identifier}' not implemented" + ) + + if curr_values: + raise ValueError("Unexpected end of the shape") + + if math.inf in (x_min, y_min) or -math.inf in (x_max, y_max): + raise ValueError("Invalid or empty shape") + + return x_min, y_min, x_max, y_max + + def move(self, x: float, y: float) -> Shape: """Moves shape coordinates in given direction. - | If neither x and y are passed, it will automatically center the shape to the origin (0,0). - | This function is an high level function, it just uses Shape.map, which is more advanced. Additionally, it is an easy way to center a shape. + | This function is a high level function, it just uses Shape.map, which is more advanced. Parameters: x (int or float): Displacement along the x-axis. @@ -288,17 +416,92 @@ def move(self, x: float = None, y: float = None) -> Shape: >>> m -5 10 l 25 10 25 30 -5 30 """ - if x is None and y is None: - x, y = [-1 * el for el in self.bounding()[0:2]] - elif x is None: - x = 0 - elif y is None: - y = 0 - # Update shape self.map(lambda cx, cy: (cx + x, cy + y)) return self + def align(self, anchor: int = 5, an: int = 5) -> Shape: + """Aligns the shape. + + | If no argument for anchor is passed, it will automatically center the shape. + + Parameters: + anchor (int): The shape's position relative to the position anchor. If anchor=7, the position anchor would be at the top left of the shape's bounding box. If anchor=5, it would be in the center. If anchor=3, it would be at the bottom right. + an (int): Alignment used for the shape (e.g. for {\\\\an8} you would use an=8). + + Returns: + A pointer to the current object. + + Examples: + .. code-block:: python3 + + print( Shape("m 10 10 l 30 10 30 20 10 20").align() ) + + >>> m 0 0 l 20 0 20 10 0 10 + """ + + y_axis_anchor, x_axis_anchor = divmod(anchor - 1, 3) + y_axis_an, x_axis_an = divmod(an - 1, 3) + x_min, y_min, x_max, y_max = self.bounding() + libass_boundings = self.bounding(exact=False) + x_min_libass, y_min_libass, x_max_libass, y_max_libass = libass_boundings + x_move = -x_min + y_move = -y_min + + # Center shape along x-axis + if x_axis_an == 0: # left + # center shape + x_move -= (x_max - x_min) / 2 + elif x_axis_an == 1: # center + # adjust for imprecise calculation from libass (when using bezier curves) + x_move -= (x_max - x_min) / 2 - (x_max_libass - x_min_libass) / 2 + elif x_axis_an == 2: # right + # center shape + x_move += (x_max - x_min) / 2 + # adjust for imprecise calculation from libass (when using bezier curves) + x_move -= (x_max - x_min) - (x_max_libass - x_min_libass) + else: + raise ValueError("an must be an integer from 1 to 9") + + # Center shape along y-axis + if y_axis_an == 0: # bottom + # center shape + y_move += (y_max - y_min) / 2 + # adjust for imprecise calculation from libass (when using bezier curves) + y_move -= (y_max - y_min) - (y_max_libass - y_min_libass) + elif y_axis_an == 1: # middle + # adjust for imprecise calculation from libass (when using bezier curves) + y_move -= (y_max - y_min) / 2 - (y_max_libass - y_min_libass) / 2 + elif y_axis_an == 2: # top + # center shape + y_move -= (y_max - y_min) / 2 + else: + raise ValueError("an must be an integer from 1 to 9") + + # Set anchor along x-axis + if x_axis_anchor == 0: # left + x_move += (x_max - x_min) / 2 + elif x_axis_anchor == 1: # center + pass + elif x_axis_anchor == 2: # right + x_move -= (x_max - x_min) / 2 + else: + raise ValueError("anchor must be an integer from 1 to 9") + + # Set anchor along y-axis + if y_axis_anchor == 0: # bottom + y_move -= (y_max - y_min) / 2 + elif y_axis_anchor == 1: # middle + pass + elif y_axis_anchor == 2: # top + y_move += (y_max - y_min) / 2 + else: + raise ValueError("anchor must be an integer from 1 to 9") + + # Update shape + self.map(lambda cx, cy: (cx + x_move, cy + y_move)) + return self + def flatten(self, tolerance: float = 1.0) -> Shape: """Splits shape's bezier curves into lines. @@ -910,7 +1113,7 @@ def rotate_on_axis_z(point, theta): shape = Shape(" ".join(shape)) # Return result centered - return shape.move() + return shape.align() @staticmethod def star(edges: int, inner_size: float, outer_size: float) -> Shape: diff --git a/tests/test_shape.py b/tests/test_shape.py index a103bfb1..77e047ea 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -1,5 +1,6 @@ import pytest from pyonfx import * +from copy import copy def test_transform(): @@ -46,20 +47,126 @@ def tr(x, y, typ): def test_bounding(): original = Shape("m -100.5 0 l 100 0 b 100 100 -100 100 -100.5 0 c") - assert original.bounding() == (-100.5, 0, 100, 100) + assert original.bounding(exact=False) == (-100.5, 0, 100, 100) original = Shape("m 0 0 l 20 0 20 10 0 10") assert original.bounding() == (0.0, 0.0, 20.0, 10.0) + original = Shape("m 0 0 l 20 0 20 10 0 10") + assert original.bounding(exact=False) == (0.0, 0.0, 20.0, 10.0) + + original = Shape( + "m 313 312 b 255 275 482 38 277 212 l 436 269 b 378 388 461 671 260 481 235 431 118 430 160 282" + ) + assert original.bounding() == ( + 150.98535796762013, + 148.88438545593218, + 436.0, + 544.871772934194, + ) + + original = Shape( + "m 313 312 b 254 287 482 38 277 212 l 436 269 b 378 388 461 671 260 481" + ) + assert original.bounding() == (260.0, 150.67823683425252, 436.0, 544.871772934194) + assert original.bounding(exact=False) == (254.0, 38.0, 482.0, 671.0) + def test_move(): original = Shape("m -100.5 0 l 100 0 b 100 100 -100 100 -100.5 0 c") dest = Shape("m -95.5 -2 l 105 -2 b 105 98 -95 98 -95.5 -2 c") assert original.move(5, -2) == dest + +def test_align(): + # Test centering original = Shape("m 0 0 l 20 0 20 10 0 10") - assert original.move() == original - assert original.move(10, 400).move() == original + assert copy(original).align() == original + assert copy(original).move(10, 400).align() == original + + # Test an + original = Shape("m 0 0 l 500 0 500 200 0 200") + assert copy(original).align(anchor=5, an=5) == original + dest1 = Shape("m -250 100 l 250 100 250 300 -250 300") + assert original.align(anchor=5, an=1) == dest1 + dest2 = Shape("m 0 100 l 500 100 500 300 0 300") + assert original.align(anchor=5, an=2) == dest2 + dest3 = Shape("m 250 100 l 750 100 750 300 250 300") + assert original.align(anchor=5, an=3) == dest3 + dest4 = Shape("m -250 0 l 250 0 250 200 -250 200") + assert original.align(anchor=5, an=4) == dest4 + dest6 = Shape("m 250 0 l 750 0 750 200 250 200") + assert original.align(anchor=5, an=6) == dest6 + dest7 = Shape("m -250 -100 l 250 -100 250 100 -250 100") + assert original.align(anchor=5, an=7) == dest7 + dest8 = Shape("m 0 -100 l 500 -100 500 100 0 100") + assert original.align(anchor=5, an=8) == dest8 + dest9 = Shape("m 250 -100 l 750 -100 750 100 250 100") + assert original.align(anchor=5, an=9) == dest9 + + original = Shape( + "m 411.87 306.36 b 385.63 228.63 445.78 147.2 536.77 144.41 630.18 147.77 697 236.33 665.81 310.49 591.86 453.18 437.07 395.59 416 316.68" + ) + dest3 = Shape( + "m 183.614 344.04 b 157.374 266.31 217.524 184.88 308.514 182.09 401.924 185.45 468.744 274.01 437.554 348.17 363.604 490.86 208.814 433.27 187.744 354.36" + ) + assert original.align(anchor=5, an=3) == dest3 + dest5 = Shape( + "m 27.929 189.655 b 1.689 111.925 61.839 30.495 152.829 27.705 246.239 31.065 313.059 119.625 281.869 193.785 207.919 336.475 53.129 278.885 32.059 199.975" + ) + assert original.align(anchor=5, an=5) == dest5 + dest7 = Shape( + "m -127.756 35.27 b -153.996 -42.46 -93.846 -123.89 -2.856 -126.68 90.554 -123.32 157.374 -34.76 126.184 39.4 52.234 182.09 -102.556 124.5 -123.626 45.59" + ) + assert original.align(anchor=5, an=7) == dest7 + + # Test anchor + original = Shape("m 0 0 l 500 0 500 200 0 200") + dest1 = Shape("m 250 -100 l 750 -100 750 100 250 100") + assert original.align(anchor=1, an=5) == dest1 + dest2 = Shape("m 0 -100 l 500 -100 500 100 0 100") + assert original.align(anchor=2, an=5) == dest2 + dest3 = Shape("m -250 -100 l 250 -100 250 100 -250 100") + assert original.align(anchor=3, an=5) == dest3 + dest4 = Shape("m 250 0 l 750 0 750 200 250 200") + assert original.align(anchor=4, an=5) == dest4 + dest5 = Shape("m 0 0 l 500 0 500 200 0 200") + assert original.align(anchor=5, an=5) == dest5 + dest6 = Shape("m -250 0 l 250 0 250 200 -250 200") + assert original.align(anchor=6, an=5) == dest6 + dest7 = Shape("m 250 100 l 750 100 750 300 250 300") + assert original.align(anchor=7, an=5) == dest7 + dest8 = Shape("m 0 100 l 500 100 500 300 0 300") + assert original.align(anchor=8, an=5) == dest8 + dest9 = Shape("m -250 100 l 250 100 250 300 -250 300") + assert original.align(anchor=9, an=5) == dest9 + + # Test anchor + an + original = Shape("m 342 352 l 338 544 734 536 736 350 b 784 320 1157 167 930 232") + dest_anchor_7_an_5 = Shape( + "m 413.5 324.427 l 409.5 516.427 805.5 508.427 807.5 322.427 b 855.5 292.427 1228.5 139.427 1001.5 204.427" + ) + assert original.align(anchor=7, an=5) == dest_anchor_7_an_5 + dest_anchor_9_an_1 = Shape( + "m -660.664 512.927 l -664.664 704.927 -268.664 696.927 -266.664 510.927 b -218.664 480.927 154.336 327.927 -72.664 392.927" + ) + assert original.align(anchor=9, an=1) == dest_anchor_9_an_1 + dest_anchor_9_an_5 = Shape( + "m -251.164 324.427 l -255.164 516.427 140.836 508.427 142.836 322.427 b 190.836 292.427 563.836 139.427 336.836 204.427" + ) + assert original.align(anchor=9, an=5) == dest_anchor_9_an_5 + dest_anchor_9_an_9 = Shape( + "m 158.336 135.927 l 154.336 327.927 550.336 319.927 552.336 133.927 b 600.336 103.927 973.336 -49.073 746.336 15.927" + ) + assert original.align(anchor=9, an=9) == dest_anchor_9_an_9 + dest_anchor_3_an_5 = Shape( + "m -251.164 -3.5 l -255.164 188.5 140.836 180.5 142.836 -5.5 b 190.836 -35.5 563.836 -188.5 336.836 -123.5" + ) + assert original.align(anchor=3, an=5) == dest_anchor_3_an_5 + dest_anchor_5_an_5 = Shape( + "m 81.168 160.464 l 77.168 352.464 473.168 344.464 475.168 158.464 b 523.168 128.464 896.168 -24.536 669.168 40.464" + ) + assert original.align(anchor=5, an=5) == dest_anchor_5_an_5 def test_flatten():