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 = (