Skip to content

Commit

Permalink
Shape: add exact bounding box calculation
Browse files Browse the repository at this point in the history
  • Loading branch information
Funami580 committed Mar 1, 2022
1 parent f576fc8 commit 2cc4a1f
Show file tree
Hide file tree
Showing 2 changed files with 155 additions and 4 deletions.
138 changes: 135 additions & 3 deletions pyonfx/shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -268,6 +278,128 @@ 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 = []

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)
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 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 = None, y: float = None) -> Shape:
"""Moves shape coordinates in given direction.
Expand Down
21 changes: 20 additions & 1 deletion tests/test_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down

0 comments on commit 2cc4a1f

Please sign in to comment.