From 1616d9e31b30d9377988cda38b2bdd3cb3269531 Mon Sep 17 00:00:00 2001 From: wangpatrick57 <wang.patrick57@gmail.com> Date: Thu, 19 Nov 2020 21:31:09 -0800 Subject: [PATCH 1/2] added bezier curve local visualizer, bezier curve json export, line intersection detection, and tests --- diagrammer/python/scene.py | 61 +++++++++++++++++++ diagrammer/scene/basic.py | 119 ++++++++++++++++++++++++++++++------- tests/local_visualizer.py | 56 ++++++++++++++--- tests/scene_tests.py | 60 +++++++++++++++++++ 4 files changed, 264 insertions(+), 32 deletions(-) diff --git a/diagrammer/python/scene.py b/diagrammer/python/scene.py index b91c57f..5796894 100644 --- a/diagrammer/python/scene.py +++ b/diagrammer/python/scene.py @@ -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: @@ -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): + 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') + + canonical_degree = math.floor(canonical_degree) + assert type(canonical_degree) is int + + 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)): + 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) diff --git a/diagrammer/scene/basic.py b/diagrammer/scene/basic.py index f32f824..ddb82da 100644 --- a/diagrammer/scene/basic.py +++ b/diagrammer/scene/basic.py @@ -331,17 +331,26 @@ def svg(self) -> str: class Arrow(SceneObject): + # Type aliases + Path = str + HEAD = 'head' TAIL = 'tail' + BEZIER_RADIANS = math.radians(30) + + STRAIGHT = 'straight' + BEZIER_CLOCK = 'bezier_clock' + BEZIER_COUNTER = 'bezier_counter' 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): @@ -363,31 +372,51 @@ 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] + # 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: + 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) - return self._edge_pos_cache[side] + if self._settings.tail_position == ArrowSettings.CENTER: + new_tail_pos = self._tail_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_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()) @@ -395,6 +424,42 @@ def get_tail_angle(self) -> float: 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 export(self) -> 'json': json = SceneObject.export(self) @@ -404,8 +469,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._path } + 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 diff --git a/tests/local_visualizer.py b/tests/local_visualizer.py index 9a739b7..01af036 100644 --- a/tests/local_visualizer.py +++ b/tests/local_visualizer.py @@ -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 = 100 + + 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] @@ -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_clock' or shape['path'] == 'bezier_counter': + 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), @@ -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(): diff --git a/tests/scene_tests.py b/tests/scene_tests.py index 4ede53e..3b68e40 100644 --- a/tests/scene_tests.py +++ b/tests/scene_tests.py @@ -7,6 +7,7 @@ import math import sys import re +import json class TestCollectionContents(basic.CollectionContents): @@ -169,6 +170,62 @@ def test_end_pos_caching(self): # check that caching actually happens self.assertEqual(arrow._get_end_pos(basic.Arrow.HEAD, say_cached = True), 'cached') + # test that bezier resets cache + before_head_pos = arrow.get_head_pos() + before_tail_pos = arrow.get_tail_pos() + + arrow.set_path(basic.Arrow.BEZIER_COUNTER) + self.assertEqual(arrow._get_end_pos(basic.Arrow.HEAD, say_cached = True), 'notcached') + self.assertEqual(arrow._get_end_pos(basic.Arrow.TAIL, say_cached = True), 'cached') + + after_head_pos = arrow.get_head_pos() + after_tail_pos = arrow.get_tail_pos() + + self.assertFalse(before_head_pos[0] == after_head_pos[0] and before_head_pos[1] == after_head_pos[1]) # both head and tail should have changed + self.assertFalse(before_tail_pos[0] == after_tail_pos[0] and before_tail_pos[1] == after_tail_pos[1]) # because both are set to EDGE + + def test_end_pos_caching_center(self): + # repeat the tests, but when head position is center + tail_obj = basic.Square() + head_obj = basic.Square() + arrow = basic.Arrow(tail_obj, head_obj, basic.ArrowSettings(None, basic.ArrowSettings.CENTER, basic.ArrowSettings.EDGE)) + + tail_obj.construct(50, '', '') + head_obj.construct(50, '', '') + tail_obj.set_pos(0, 0) + head_obj.set_pos(100, 100) + + # check that the same call twice works the same + self.assertEqual(tuple(round(coord) for coord in arrow.get_tail_pos()), (25, 25)) + self.assertEqual(tuple(round(coord) for coord in arrow.get_tail_pos()), (25, 25)) + self.assertEqual(tuple(round(coord) for coord in arrow.get_head_pos()), (100, 100)) + self.assertEqual(tuple(round(coord) for coord in arrow.get_head_pos()), (100, 100)) + + # check that reposition recaches + tail_obj.set_pos(200, 0) + + self.assertEqual(tuple(round(coord) for coord in arrow.get_tail_pos()), (175, 25)) + self.assertEqual(tuple(round(coord) for coord in arrow.get_tail_pos()), (175, 25)) + self.assertEqual(tuple(round(coord) for coord in arrow.get_head_pos()), (100, 100)) + self.assertEqual(tuple(round(coord) for coord in arrow.get_head_pos()), (100, 100)) + + # check that caching actually happens + self.assertEqual(arrow._get_end_pos(basic.Arrow.HEAD, say_cached = True), 'cached') + + # test that bezier resets cache + before_head_pos = arrow.get_head_pos() + before_tail_pos = arrow.get_tail_pos() + + arrow.set_path(basic.Arrow.BEZIER_COUNTER) + self.assertEqual(arrow._get_end_pos(basic.Arrow.HEAD, say_cached = True), 'notcached') + self.assertEqual(arrow._get_end_pos(basic.Arrow.TAIL, say_cached = True), 'cached') + + after_head_pos = arrow.get_head_pos() + after_tail_pos = arrow.get_tail_pos() + + self.assertTrue(before_head_pos[0] == after_head_pos[0] and before_head_pos[1] == after_head_pos[1]) # only tail should have changed + self.assertFalse(before_tail_pos[0] == after_tail_pos[0] and before_tail_pos[1] == after_tail_pos[1]) # because head was set to CENTER + def test_basic_shape(self): # test constructor shape = basic.BasicShape() @@ -405,6 +462,9 @@ def test_scene_svg_generation(self): def test_snapshot_svg_generation(self): snapshot_data = self.snapshot.export(scene_format='svg') + + print(json.dumps(snapshot_data, indent=2)) + self.assertEqual(snapshot_data['scenes']['test'], self.scene.svg()) # not testing error and output because lots of other tests already do that; it's not my concern here From 04d6c5e8a793fb301a0131068a71755ec24ef245 Mon Sep 17 00:00:00 2001 From: wangpatrick57 <wang.patrick57@gmail.com> Date: Thu, 19 Nov 2020 21:50:43 -0800 Subject: [PATCH 2/2] made both clock and counter export as just 'bezier' --- diagrammer/scene/basic.py | 9 ++++++++- tests/local_visualizer.py | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/diagrammer/scene/basic.py b/diagrammer/scene/basic.py index ddb82da..660536c 100644 --- a/diagrammer/scene/basic.py +++ b/diagrammer/scene/basic.py @@ -341,6 +341,7 @@ class Arrow(SceneObject): STRAIGHT = 'straight' 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 @@ -460,6 +461,12 @@ def get_bezier_poses(self) -> ((float, float), (float, float)): 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) @@ -469,7 +476,7 @@ def export(self) -> 'json': 'head_x': self.get_head_x(), 'head_y': self.get_head_y(), 'arrow_type': self._settings.arrow_type, - 'path': self._path + 'path': self._get_path_export_str() } if self._path == Arrow.BEZIER_CLOCK or self._path == Arrow.BEZIER_COUNTER: diff --git a/tests/local_visualizer.py b/tests/local_visualizer.py index 01af036..13ee26c 100644 --- a/tests/local_visualizer.py +++ b/tests/local_visualizer.py @@ -28,7 +28,7 @@ def distance(p1, p2): def bezier(self: ImageDraw, point1, point2, point3, point4, fill = None): curr_t = 0 - max_t = 100 + max_t = 500 for curr_t in range(max_t + 1): curr_tdec = curr_t / max_t @@ -133,7 +133,7 @@ def generate_single_png(diagram_data: dict, dir_relative_path: str, filename: st if 'shape' not in shape: 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_clock' or shape['path'] == 'bezier_counter': + 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 = (