diff --git a/pyonfx/shape.py b/pyonfx/shape.py index cff9019c..fa36d2ab 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,6 +278,154 @@ def compute_edges(x, y): self.map(compute_edges) return x0, y0, x1, y1 + 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, bounds, points = [], [{}, {}], [] + a, b, c = None, None, None + t, t1, t2 = None, None, None + b2ac, sqrtb2ac = None, None + + 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, y, j, jlen, mt = None, None, len(tvalues), len(tvalues), None + + while j > 0: + j -= 1 + t = tvalues[j] + mt = 1 - t + x = ( + (mt * mt * mt * x0) + + (3 * mt * mt * t * x1) + + (3 * mt * t * t * x2) + + (t * t * t * x3) + ) + bounds[0][j] = x + y = ( + (mt * mt * mt * y0) + + (3 * mt * mt * t * y1) + + (3 * mt * t * t * y2) + + (t * t * t * y3) + ) + bounds[1][j] = y + + bounds[0][jlen] = x0 + bounds[1][jlen] = y0 + bounds[0][jlen + 1] = x3 + bounds[1][jlen + 1] = y3 + + def update_length(dict_obj, new_len): + new_dict = {} + + for i in range(new_len): + if i not in dict_obj: + continue + + new_dict[i] = dict_obj[i] + + return new_dict + + bounds[0] = update_length(bounds[0], jlen + 2) + bounds[1] = update_length(bounds[1], jlen + 2) + + return ( + min(bounds[0].values()), # x_min + min(bounds[1].values()), # y_min + max(bounds[0].values()), # x_max + max(bounds[1].values()), # 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) + index = 0 + + while index < len(instructions): + instruction = instructions[index] + is_identifier = instruction.isalpha() + index += 1 + + 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 ( + x_min == math.inf + or y_min == math.inf + or x_max == -math.inf + or y_max == -math.inf + ): + raise ValueError("Invalid or empty shape") + + return x_min, y_min, x_max, y_max + def move(self, x: float = None, y: float = None) -> Shape: """Moves shape coordinates in given direction. diff --git a/tests/test_shape.py b/tests/test_shape.py index a103bfb1..1f3fd136 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -46,11 +46,30 @@ 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")