Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bezier #39

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions diagrammer/python/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import random
import json
import time
import math


def is_instance_for_bld(bld: 'python bld value', type_obj: type) -> bool:
Expand Down Expand Up @@ -463,6 +464,66 @@ def gps(self) -> None:
else:
self._width = self._height = 0

# make overlapping references' path BEZIER_CLOCK
ref_by_angle = defaultdict(list)

for ref in self._references:
# get canonical degree, which is floored and [0, 180)
canonical_degree = math.degrees(ref.get_head_angle())

while not (0 <= canonical_degree < 180):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this basically turning the head angle into the tail angle? right cause it's basically just shifting the "origin" of the angle measurement from head to tail and geometrically that's basically just transforming head angle into tail angle

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also I think it'd be better design to make this a part of the Arrow class so that you can reuse it outside of gps if needed (and so that gps doesn't calculate a property of the arrow)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this is a gps specific thing though not an Arrow thing because only this specific part of GPS needs "either the head or tail, whichever one's between 0 and 180"

if canonical_degree < 0:
canonical_degree += 180
elif canonical_degree >= 180:
canonical_degree -= 180
else:
raise FloatingPointError(f'Scene.gps: canonical_degree {canonical_degree} is just wrong')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this not possible unless you had a complex number ig

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not possible at all theoretically but I put this here just in case


canonical_degree = math.floor(canonical_degree)
assert type(canonical_degree) is int
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does math.floor() ever not return int?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in Java it doesn't so this is just here in case Python changes math.floor()


ref_by_angle[canonical_degree].append(ref)

# loop through all angle lists
for same_angle_list in ref_by_angle.values():
# for each angle list, loop through all pairs of refs
for i in range(len(same_angle_list)):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fine, I just thought I'd drop an alternate way to do it in case you decide you prefer that:

for i, ref1 in enumerate(same_angle_list):
for j, ref2 in enumerate(same_angle_list[i+1:]):
...

although I think slices might be copies so your way avoids some overhead if that's the case but just a thought

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm yeah that's better design according to the problem I graded for ics33

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slicing doesn't copy actually so I'll change it to that

for j in range(i + 1, len(same_angle_list)):
ref1 = same_angle_list[i]
ref2 = same_angle_list[j]

# only set them to bezier if they overlap, which means they have to BOTH be straight
if ref1.get_path() == basic.Arrow.STRAIGHT and ref2.get_path() == basic.Arrow.STRAIGHT:
ref1_tx, ref1_ty = ref1.get_tail_pos()
ref1_hx, ref1_hy = ref1.get_head_pos()
ref2_tx, ref2_ty = ref2.get_tail_pos()
ref2_hx, ref2_hy = ref2.get_head_pos()

# set x is overlapping
x_is_overlapping = True
ref1_minx = min(ref1_tx, ref1_hx)
ref1_maxx = max(ref1_tx, ref1_hx)
ref2_minx = min(ref2_tx, ref2_hx)
ref2_maxx = max(ref2_tx, ref2_hx)

if ref1_minx > ref2_maxx or ref2_minx > ref1_maxx: # > not >= because == also means overlapping
x_is_overlapping = False

# set y is overlapping
y_is_overlapping = True
ref1_miny = min(ref1_ty, ref1_hy)
ref1_maxy = max(ref1_ty, ref1_hy)
ref2_miny = min(ref2_ty, ref2_hy)
ref2_maxy = max(ref2_ty, ref2_hy)

if ref1_miny > ref2_maxy or ref2_miny > ref1_maxy: # > not >= because == also means overlapping
y_is_overlapping = False

# if we got through all of the checks, make the lines bezier (use set_path() to clear the cache)
if x_is_overlapping and y_is_overlapping:
ref1.set_path(basic.Arrow.BEZIER_CLOCK)
ref2.set_path(basic.Arrow.BEZIER_CLOCK)

def _position_collection(self, collection_or_container: 'basic.Collection or basic.Container', start_row: int, start_col: int) -> None:
current_row = start_row
self.set_grid(collection_or_container, current_row, start_col)
Expand Down
126 changes: 103 additions & 23 deletions diagrammer/scene/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,17 +331,27 @@ def svg(self) -> str:


class Arrow(SceneObject):
# Type aliases
Path = str

HEAD = 'head'
TAIL = 'tail'
BEZIER_RADIANS = math.radians(30)

STRAIGHT = 'straight'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could add the path settings to ArrowSettings instead maybe

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's what I did at first lol but there was some problem with it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah the problem is I have a class variable called smth like PY_REFERENCE_ARROW_SETTINGS that all PyReferences are initialized with, so if I modify it it would modify the settings of all PyReferences.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and it wasn't good design to allow the user to modify the settings. you could have "get_settings()" function in Arrow and have the user modify that directly, but the problem is when you modify certain settings you need to reset the cache. Thus, you need a "set_settings()" function, but users (as in us) might forget to call set_settings() and just do get_settings() directly. Another solution is to have a set_path() function and no get_settings() function, but that means Arrow has to know about the details of ArrowSettings, which didn't seem right. Overall, it seems like the way we designed settings isn't meant for them to be modified after creation

BEZIER_CLOCK = 'bezier_clock'
BEZIER_COUNTER = 'bezier_counter'
BEZIER_EXPORT_STR = 'bezier'

def __init__(self, tail_obj: BasicShape, head_obj: BasicShape, settings: ArrowSettings):
self._tail_obj = tail_obj
self._head_obj = head_obj
self._settings = settings
self._path = Arrow.STRAIGHT

# caching
self._old_tail_pos = None
self._old_head_pos = None
self._old_tail_obj_pos = None
self._old_head_obj_pos = None
self._edge_pos_cache = {Arrow.HEAD: None, Arrow.TAIL: None}

def get_tail_pos(self) -> (float, float):
Expand All @@ -363,38 +373,100 @@ def get_head_y(self) -> float:
return self.get_head_pos()[1]

def _get_end_pos(self, side: str, say_cached = False) -> (float, float):
if side == Arrow.TAIL:
edge_angle = self.get_tail_angle()
arrow_position = self._settings.tail_position
base_obj = self._tail_obj
elif side == Arrow.HEAD:
edge_angle = self.get_head_angle()
arrow_position = self._settings.head_position
base_obj = self._head_obj
else:
if side != Arrow.TAIL and side != Arrow.HEAD:
raise KeyError(f'Arrow._get_end_pos: side {side} is not a valid input')

if arrow_position == ArrowSettings.CENTER:
return base_obj.get_pos()
elif arrow_position == ArrowSettings.EDGE:
if self._old_tail_pos == self._tail_obj.get_pos() and self._old_head_pos == self._head_obj.get_pos():
if say_cached:
return 'cached'
# if NEITHER position has changed, and cache exists, use cache
if self._old_tail_obj_pos == self._tail_obj.get_pos() and self._old_head_obj_pos == self._head_obj.get_pos() and self._edge_pos_cache[side] != None:
if say_cached:
return 'cached'

return self._edge_pos_cache[side]
return self._edge_pos_cache[side]
# otherwise, calculate BOTH positions and cache them, then return
else:
self._old_tail_obj_pos = self._tail_obj.get_pos()
self._old_head_obj_pos = self._head_obj.get_pos()

if self._settings.head_position == ArrowSettings.CENTER:
new_head_pos = self._head_obj.get_pos()
else:
self._old_tail_pos = self._tail_obj.get_pos()
self._old_head_pos = self._head_obj.get_pos()
self._edge_pos_cache[Arrow.TAIL] = self._tail_obj.calculate_edge_pos(self.get_tail_angle())
self._edge_pos_cache[Arrow.HEAD] = self._head_obj.calculate_edge_pos(self.get_head_angle())
return self._edge_pos_cache[side]
edge_angle = self.get_head_angle()

if self._path == Arrow.BEZIER_CLOCK:
edge_angle -= Arrow.BEZIER_RADIANS
elif self._path == Arrow.BEZIER_COUNTER:
edge_angle += Arrow.BEZIER_RADIANS

new_head_pos = self._head_obj.calculate_edge_pos(edge_angle)

if self._settings.tail_position == ArrowSettings.CENTER:
new_tail_pos = self._tail_obj.get_pos()
else:
edge_angle = self.get_tail_angle()

if self._path == Arrow.BEZIER_CLOCK:
edge_angle -= Arrow.BEZIER_RADIANS
elif self._path == Arrow.BEZIER_COUNTER:
edge_angle += Arrow.BEZIER_RADIANS

new_tail_pos = self._tail_obj.calculate_edge_pos(edge_angle)

self._edge_pos_cache[Arrow.HEAD] = new_head_pos
self._edge_pos_cache[Arrow.TAIL] = new_tail_pos

if say_cached:
return 'notcached'

return self._edge_pos_cache[side]

def get_tail_angle(self) -> float:
return math.atan2(self._tail_obj.get_y() - self._head_obj.get_y(), self._head_obj.get_x() - self._tail_obj.get_x())

def get_head_angle(self) -> float:
return math.atan2(self._head_obj.get_y() - self._tail_obj.get_y(), self._tail_obj.get_x() - self._head_obj.get_x())

def get_path(self) -> str:
return self._path

def set_path(self, path: Path) -> None:
# clear cache because this changes the positioning
self._edge_pos_cache[Arrow.HEAD] = None
self._edge_pos_cache[Arrow.TAIL] = None
self._path = path

def get_bezier_poses(self) -> ((float, float), (float, float)):
assert self._path == Arrow.BEZIER_CLOCK or self._path == Arrow.BEZIER_COUNTER

point1 = self.get_tail_pos()
point4 = self.get_head_pos()

dx = point4[0] - point1[0]
dy = -point4[1] + point1[1]
slope = dy / dx
parallel_radians = math.atan2(dy, dx)
perpendicular_radians = math.atan2(-dx, dy)
dist = math.hypot(point4[0] - point1[0], point4[1] - point1[1])
p2base = (point1[0] + math.cos(parallel_radians) * dist / 4, point1[1] - math.sin(parallel_radians) * dist / 4)
p3base = (point1[0] + math.cos(parallel_radians) * dist * 3 / 4, point1[1] - math.sin(parallel_radians) * dist * 3 / 4)

basedx = dist / 4 * math.cos(perpendicular_radians)
basedy = dist / 4 * math.sin(perpendicular_radians)

if self._path == Arrow.BEZIER_COUNTER:
basedx *= -1
basedy *= -1

point2 = (p2base[0] - basedx, p2base[1] + basedy)
point3 = (p3base[0] - basedx, p3base[1] + basedy)

return (point2, point3)

def _get_path_export_str(self):
if self._path == Arrow.STRAIGHT:
return Arrow.STRAIGHT
elif self._path == Arrow.BEZIER_CLOCK or self._path == Arrow.BEZIER_COUNTER:
return Arrow.BEZIER_EXPORT_STR

def export(self) -> 'json':
json = SceneObject.export(self)

Expand All @@ -404,8 +476,16 @@ def export(self) -> 'json':
'head_x': self.get_head_x(),
'head_y': self.get_head_y(),
'arrow_type': self._settings.arrow_type,
'path': self._get_path_export_str()
}

if self._path == Arrow.BEZIER_CLOCK or self._path == Arrow.BEZIER_COUNTER:
tailclose_pos, headclose_pos = self.get_bezier_poses()
add_json['tailclose_x'] = tailclose_pos[0]
add_json['tailclose_y'] = tailclose_pos[1]
add_json['headclose_x'] = headclose_pos[0]
add_json['headclose_y'] = headclose_pos[1]

for key, val in add_json.items():
json[key] = val

Expand Down
56 changes: 47 additions & 9 deletions tests/local_visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,48 @@
import os
import shutil
import utils
import math
utils.setup_pythonpath_for_tests()

from diagrammer import python as py_diagrammer


# MONKEY PATCHING BEZIER
def tuple_mult(tup, fac):
return tuple(a * fac for a in tup)

def tuple_add(tup1, tup2):
assert(len(tup1) == len(tup2))

ret = [0] * len(tup1)

for i in range(len(tup1)):
ret[i] = tup1[i] + tup2[i]

return tuple(ret)

def distance(p1, p2):
return math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)

def bezier(self: ImageDraw, point1, point2, point3, point4, fill = None):
curr_t = 0
max_t = 500

for curr_t in range(max_t + 1):
curr_tdec = curr_t / max_t
sp1sp1 = tuple_add(tuple_mult(point1, 1 - curr_tdec), tuple_mult(point2, curr_tdec))
sp1sp2 = tuple_add(tuple_mult(point2, 1 - curr_tdec), tuple_mult(point3, curr_tdec))
sp1 = tuple_add(tuple_mult(sp1sp1, 1 - curr_tdec), tuple_mult(sp1sp2, curr_tdec))
sp2sp1 = tuple_add(tuple_mult(point2, 1 - curr_tdec), tuple_mult(point3, curr_tdec))
sp2sp2 = tuple_add(tuple_mult(point3, 1 - curr_tdec), tuple_mult(point4, curr_tdec))
sp2 = tuple_add(tuple_mult(sp2sp1, 1 - curr_tdec), tuple_mult(sp2sp2, curr_tdec))
bezier_point = tuple_add(tuple_mult(sp1, 1 - curr_tdec), tuple_mult(sp2, curr_tdec))
self.point(bezier_point, fill)

ImageDraw.ImageDraw.bezier = bezier
# MONKEY PATCHING OVER


# MONKEY PATCHING ROUNDED RECTANGLE
def rounded_rectangle(self: ImageDraw, xy, corner_radius, outline = None):
upper_left_point = xy[0]
Expand Down Expand Up @@ -94,7 +131,10 @@ def generate_single_png(diagram_data: dict, dir_relative_path: str, filename: st
# draw shapes
for shape in diagram_data:
if 'shape' not in shape:
draw.line(((shape['tail_x'], shape['tail_y']), (shape['head_x'], shape['head_y'])), fill = TAPESTRY_GOLD)
if shape['path'] == 'straight':
draw.line(((shape['tail_x'], shape['tail_y']), (shape['head_x'], shape['head_y'])), fill = TAPESTRY_GOLD)
elif shape['path'] == 'bezier':
draw.bezier((shape['tail_x'], shape['tail_y']), (shape['tailclose_x'], shape['tailclose_y']), (shape['headclose_x'], shape['headclose_y']), (shape['head_x'], shape['head_y']), fill = TAPESTRY_GOLD)
else:
xy = (
(shape['x'] - shape['width'] / 2, shape['y'] - shape['height'] / 2),
Expand Down Expand Up @@ -126,17 +166,15 @@ def generate_single_png(diagram_data: dict, dir_relative_path: str, filename: st


CODE = '''
a = 5
aa = 7.5
aaa = True
aaaa = None
b = 'hello world'
c = [6, ['hello', 2]]
d = [1, 2, 'yello']
x = 1
y = 'hello world'
a = [1, 2, 3]
b = [a, 4, 5]
c = [b, a, 0]
'''

if __name__ == '__main__':
full_diagram_data = py_diagrammer.generate_diagrams_for_code(CODE, [], primitive_era = True)
full_diagram_data = py_diagrammer.generate_diagrams_for_code(CODE, [], primitive_era = False)

for flag_num, flag_data in enumerate(full_diagram_data):
for scope, diagram_data in flag_data['scenes'].items():
Expand Down
Loading