From 4792378ddcfc1c953682125068315341404bf325 Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Fri, 29 Oct 2021 22:51:06 +0200 Subject: [PATCH 01/12] Slim down distance rect to point See https://stackoverflow.com/questions/5254838/calculating-distance-between-a-point-and-a-rectangular-box-nearest-point --- gaphas/geometry.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/gaphas/geometry.py b/gaphas/geometry.py index 403090f3..735953e6 100644 --- a/gaphas/geometry.py +++ b/gaphas/geometry.py @@ -302,21 +302,12 @@ def distance_rectangle_point(rect: Rect, point: Point) -> float: >>> distance_rectangle_point((0, 0, 10, 10), (-1, 11)) 2 """ - dx = dy = 0.0 px, py = point rx, ry, rw, rh = rect + dx = max(0.0, rx - px, px - (rx + rw)) + dy = max(0.0, ry - py, py - (ry + rh)) - if px < rx: - dx = rx - px - elif px > rx + rw: - dx = px - (rx + rw) - - if py < ry: - dy = ry - py - elif py > ry + rh: - dy = py - (ry + rh) - - return abs(dx) + abs(dy) + return dx + dy def point_on_rectangle(rect: Rect, point: Point, border: bool = False) -> Point: From c484e6a1c8f19501931a1a105dcf686cdac9b6c9 Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Fri, 29 Oct 2021 23:23:44 +0200 Subject: [PATCH 02/12] Add a function that finds lines colliding with other items Using the other items bounding box for now (should be something more precise). --- gaphas/collision.py | 34 ++++++++++++++++++++++++++++++++++ gaphas/geometry.py | 17 +++++++++++++++++ gaphas/item.py | 8 ++++++-- gaphas/quadtree.py | 4 ++++ tests/test_collision.py | 25 +++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 gaphas/collision.py create mode 100644 tests/test_collision.py diff --git a/gaphas/collision.py b/gaphas/collision.py new file mode 100644 index 00000000..9f0088c6 --- /dev/null +++ b/gaphas/collision.py @@ -0,0 +1,34 @@ +"""Collision avoiding. + +Reroute lines when they cross elements and other lines. +""" + +from typing import Iterable + +from gaphas.geometry import intersect_rectangle_line +from gaphas.item import Item, Line +from gaphas.quadtree import Quadtree + + +def colliding_lines(qtree: Quadtree) -> Iterable[tuple[Line, Item]]: + lines = (item for item in qtree.items if isinstance(item, Line)) + for line in lines: + items = qtree.find_intersect(qtree.get_bounds(line)) + if not items: + continue + + segments = [ + ( + line.matrix_i2c.transform_point(*start), + line.matrix_i2c.transform_point(*end), + ) + for start, end in line.segments + ] + for item in items: + if item is line: + continue + bounds = qtree.get_bounds(item) + for seg_start, seg_end in segments: + if intersect_rectangle_line(bounds, seg_end, seg_start): + yield (line, item) + break diff --git a/gaphas/geometry.py b/gaphas/geometry.py index 735953e6..b7641adc 100644 --- a/gaphas/geometry.py +++ b/gaphas/geometry.py @@ -558,6 +558,23 @@ def intersect_line_line( return x, y +def intersect_rectangle_line( + rect: Rect, line_start: Point, line_end: Point +) -> set[Point]: + rx, ry, rw, rh = rect + side_starts = [(rx, ry), (rx + rw, ry), (rx + rw, ry + rh), (rx, ry + rh)] + side_ends = [(rx + rw, ry), (rx + rw, ry + rh), (rx, ry + rh), (rx, ry)] + return set( + filter( + None, + ( + intersect_line_line(line_start, line_end, side_start, side_end) + for side_start, side_end in zip(side_starts, side_ends) + ), + ) + ) + + def rectangle_contains(inner: Rect, outer: Rect) -> bool: """Returns True if ``inner`` rect is contained in ``outer`` rect.""" ix, iy, iw, ih = inner diff --git a/gaphas/item.py b/gaphas/item.py index 60a6965c..44e10348 100644 --- a/gaphas/item.py +++ b/gaphas/item.py @@ -341,6 +341,11 @@ def horizontal(self, horizontal: bool) -> None: self._horizontal = horizontal self.update_orthogonal_constraints(self.orthogonal) + @property + def segments(self): + hpos = [h.pos for h in self._handles] + return zip(hpos, hpos[1:]) + def insert_handle(self, index: int, handle: Handle) -> None: self._handles.insert(index, handle) @@ -395,11 +400,10 @@ def point(self, x: float, y: float) -> float: >>> f"{a.point(29, 29):.3f}" '0.784' """ - hpos = [h.pos for h in self._handles] p = (x, y) distance, _point = min( distance_line_point(start, end, p) # type: ignore[arg-type] - for start, end in zip(hpos[:-1], hpos[1:]) + for start, end in self.segments ) return max(0.0, distance - self.fuzziness) diff --git a/gaphas/quadtree.py b/gaphas/quadtree.py index 1605ddc6..c97cecc5 100644 --- a/gaphas/quadtree.py +++ b/gaphas/quadtree.py @@ -127,6 +127,10 @@ def soft_bounds(self) -> Bounds: y1 = max(map(add, x_y_w_h[1], x_y_w_h[3])) return x0, y0, x1 - x0, y1 - y0 + @property + def items(self): + return self._ids.keys() + def add(self, item: T, bounds: Bounds, data: D | None = None) -> None: """Add an item to the tree. diff --git a/tests/test_collision.py b/tests/test_collision.py new file mode 100644 index 00000000..80678c8c --- /dev/null +++ b/tests/test_collision.py @@ -0,0 +1,25 @@ +from gaphas.collision import colliding_lines +from gaphas.connections import Connections +from gaphas.item import Element, Item, Line +from gaphas.quadtree import Quadtree + + +def test_colliding_lines(): + connections = Connections() + qtree: Quadtree[Item, None] = Quadtree() + + line = Line(connections=connections) + line.head.pos = (0, 50) + line.tail.pos = (200, 50) + + element = Element(connections=connections) + element.height = 100 + element.width = 100 + element.matrix.translate(50, 0) + + qtree.add(line, (0, 50, 200, 50)) + qtree.add(element, (50, 0, 150, 100)) + + collisions = list(colliding_lines(qtree)) + + assert (line, element) in collisions From 28cc810a06b910ca9f00b45714d30cdddf9d4165 Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Sat, 30 Oct 2021 10:49:40 +0200 Subject: [PATCH 03/12] Add A* routing code to collision avoiding code --- gaphas/collision.py | 171 +++++++++++++++++++++++++++++++++++++++- tests/test_collision.py | 70 +++++++++++++++- 2 files changed, 239 insertions(+), 2 deletions(-) diff --git a/gaphas/collision.py b/gaphas/collision.py index 9f0088c6..aa3064ff 100644 --- a/gaphas/collision.py +++ b/gaphas/collision.py @@ -2,12 +2,30 @@ Reroute lines when they cross elements and other lines. """ +from __future__ import annotations -from typing import Iterable +from operator import attrgetter +from typing import Callable, Iterable, Literal, NamedTuple, Tuple, Union from gaphas.geometry import intersect_rectangle_line from gaphas.item import Item, Line from gaphas.quadtree import Quadtree +from gaphas.view.model import Model + +Pos = Tuple[int, int] + + +class Node(NamedTuple): + parent: object | None # type should be Node + position: Pos + direction: Pos + g: int + f: int + + +Walker = Callable[[int, int], bool] +Heuristic = Callable[[int, int], int] +Weight = Callable[[int, int, Node], Union[int, Literal["inf"]]] def colliding_lines(qtree: Quadtree) -> Iterable[tuple[Line, Item]]: @@ -32,3 +50,154 @@ def colliding_lines(qtree: Quadtree) -> Iterable[tuple[Line, Item]]: if intersect_rectangle_line(bounds, seg_end, seg_start): yield (line, item) break + + +def update_colliding_lines(canvas: Model, qtree: Quadtree, grid_size: int = 20) -> None: + for line, item in colliding_lines(qtree): + # find start and end pos in terms of the grid + start_x = int(line.head.pos.x / grid_size) + start_y = int(line.head.pos.y / grid_size) + end_x = int(line.tail.pos.x / grid_size) + end_y = int(line.tail.pos.y / grid_size) + + def weight(x, y, current_node): + return 1 + + full_path = route( + (start_x, start_y), + (end_x, end_y), + weight=weight, + heuristic=manhattan_distance(end_x, end_y), + ) + full_path + # when moving items, do not update selected items + # when moving a handle, selected items can be rerouted + # reduce path: find corner points to put handles + # update handles on line with new points + canvas.request_update(line) + + +# Heuristics: + + +def constant_heuristic(cost): + def heuristic(_x, _y): + return cost + + return heuristic + + +def manhattan_distance(end_x, end_y): + def heuristic(x, y): + return abs(x - end_x) + abs(y - end_y) + + return heuristic + + +def quadratic_distance(end_x, end_y): + def heuristic(x, y): + return ((x - end_x) ** 2) + ((y - end_y) ** 2) + + return heuristic + + +# Weight: + + +def constant_weight(_node_x, _node_y, _node, cost=1): + return cost + + +def prefer_orthogonal(node_x, node_y, node, cost=5): + return 1 if node_x[0] == node.position[0] or node_y[1] == node.position[1] else cost + + +def route( + start: Pos, + end: Pos, + weight: Weight, + heuristic: Heuristic = constant_heuristic(1), +) -> list[Pos] | None: + """Simple A* router/solver. + + This solver is tailored towards grids (mazes). + + Args: + start: Start position + end: Final position + weight: + Provide a cost for the move to the new position (x, y). Weight can be "inf" + to point out you can never move there. Weight can consist of many parts: + a weight of travel (normally 1), but also a cost for bending, for example. + heuristic: + An (optimistic) estimate of how long it would take to reach `end` from the + position (x, y). Normally this is some distance (manhattan or quadratic). + Default is a constant distance (1), which would make it a standard shortest + path algorithm a la Dijkstra. + + Returns: + A list of the shortest path found, or None. + """ + open_nodes = [Node(None, start, (0, 0), 0, 0)] + closed_positions = set() + + f = attrgetter("f") + directions = [ + (0, -1), + (0, 1), + (-1, 0), + (1, 0), + (-1, -1), + (-1, 1), + (1, -1), + (1, 1), + ] + + while open_nodes: + current_node = min(open_nodes, key=f) + + if current_node.position == end: + return reconstruct_path(current_node) + + for dir_x, dir_y in directions: + node_x = current_node.position[0] + dir_x + node_y = current_node.position[1] + dir_y + + w = weight(node_x, node_y, current_node) + if w == "inf": + continue + + node_position = (node_x, node_y) + + if node_position in closed_positions: + continue + + g = current_node.g + w + + for open_node in open_nodes: + if node_position == open_node.position and g > open_node.g: + continue + + open_nodes.append( + Node( + current_node, + node_position, + (dir_x, dir_y), + g, + g + heuristic(node_x, node_y), + ) + ) + + open_nodes.remove(current_node) + closed_positions.add(current_node.position) + + return None + + +def reconstruct_path(node: Node) -> list[Pos]: + path = [] + current = node + while current: + path.append(current.position) # Add direction + current = current.parent # type: ignore[assignment] + return path[::-1] diff --git a/tests/test_collision.py b/tests/test_collision.py index 80678c8c..3ab6c4fc 100644 --- a/tests/test_collision.py +++ b/tests/test_collision.py @@ -1,4 +1,4 @@ -from gaphas.collision import colliding_lines +from gaphas.collision import colliding_lines, manhattan_distance, route from gaphas.connections import Connections from gaphas.item import Element, Item, Line from gaphas.quadtree import Quadtree @@ -23,3 +23,71 @@ def test_colliding_lines(): collisions = list(colliding_lines(qtree)) assert (line, element) in collisions + + +def test_maze(): + + _maze = [ + [(0), 0, 0, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0, (0), 0, 0], + [0, 0, 0, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ] + + start = (0, 0) + end = (7, 6) + + def weight(x, y, _current_node): + return ( + 1 + if 0 <= x < len(_maze) and 0 <= y < len(_maze[x]) and not _maze[y][x] + else "inf" + ) + + path = route(start, end, weight=weight, heuristic=manhattan_distance(*end)) + dump_maze(_maze, path) + assert path == [ + (0, 0), + (1, 1), + (2, 2), + (3, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 6), + (7, 6), + ], path + + +def test_unsolvable_maze(): + + start = (0, 0) + end = (3, 3) + + def weight(x, y, _current_node): + wall = y == 2 + return 1 if 0 <= x < 4 and 0 <= y < 4 and not wall else "inf" + + path = route(start, end, weight=weight) + assert path is None, path + + +def dump_maze(maze, path): + for y, row in enumerate(maze): + for x, v in enumerate(row): + if (x, y) == path[0]: + p = "S" + elif (x, y) == path[-1]: + p = "E" + elif (x, y) in path: + p = "x" + else: + p = v + print(p, end=" ") + print() From 4a810c2b98883adc3ffd086bfe2c08824869f6d3 Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Sat, 30 Oct 2021 17:21:25 +0200 Subject: [PATCH 04/12] Return direction for a path found This helps when we need to determine where to place the bends. --- gaphas/collision.py | 16 ++++++++-------- tests/test_collision.py | 30 +++++++++++++++--------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/gaphas/collision.py b/gaphas/collision.py index aa3064ff..2cca11e9 100644 --- a/gaphas/collision.py +++ b/gaphas/collision.py @@ -117,7 +117,7 @@ def route( end: Pos, weight: Weight, heuristic: Heuristic = constant_heuristic(1), -) -> list[Pos] | None: +) -> list[Pos]: """Simple A* router/solver. This solver is tailored towards grids (mazes). @@ -136,7 +136,7 @@ def route( path algorithm a la Dijkstra. Returns: - A list of the shortest path found, or None. + A list of the shortest path found in tuples (position, direction), or []. """ open_nodes = [Node(None, start, (0, 0), 0, 0)] closed_positions = set() @@ -159,9 +159,9 @@ def route( if current_node.position == end: return reconstruct_path(current_node) - for dir_x, dir_y in directions: - node_x = current_node.position[0] + dir_x - node_y = current_node.position[1] + dir_y + for direction in directions: + node_x = current_node.position[0] + direction[0] + node_y = current_node.position[1] + direction[1] w = weight(node_x, node_y, current_node) if w == "inf": @@ -182,7 +182,7 @@ def route( Node( current_node, node_position, - (dir_x, dir_y), + direction, g, g + heuristic(node_x, node_y), ) @@ -191,13 +191,13 @@ def route( open_nodes.remove(current_node) closed_positions.add(current_node.position) - return None + return [] def reconstruct_path(node: Node) -> list[Pos]: path = [] current = node while current: - path.append(current.position) # Add direction + path.append((current.position, current.direction)) current = current.parent # type: ignore[assignment] return path[::-1] diff --git a/tests/test_collision.py b/tests/test_collision.py index 3ab6c4fc..55268b16 100644 --- a/tests/test_collision.py +++ b/tests/test_collision.py @@ -47,22 +47,22 @@ def weight(x, y, _current_node): return ( 1 if 0 <= x < len(_maze) and 0 <= y < len(_maze[x]) and not _maze[y][x] - else "inf" + else 10 ) - path = route(start, end, weight=weight, heuristic=manhattan_distance(*end)) - dump_maze(_maze, path) - assert path == [ - (0, 0), - (1, 1), - (2, 2), - (3, 3), - (3, 4), - (4, 5), - (5, 6), - (6, 6), - (7, 6), - ], path + path_and_dir = route(start, end, weight=weight, heuristic=manhattan_distance(*end)) + dump_maze(_maze, [pd[0] for pd in path_and_dir]) + assert path_and_dir == [ + ((0, 0), (0, 0)), + ((1, 1), (1, 1)), + ((2, 2), (1, 1)), + ((3, 3), (1, 1)), + ((3, 4), (0, 1)), + ((4, 5), (1, 1)), + ((5, 6), (1, 1)), + ((6, 6), (1, 0)), + ((7, 6), (1, 0)), + ], path_and_dir def test_unsolvable_maze(): @@ -75,7 +75,7 @@ def weight(x, y, _current_node): return 1 if 0 <= x < 4 and 0 <= y < 4 and not wall else "inf" path = route(start, end, weight=weight) - assert path is None, path + assert path == [], path def dump_maze(maze, path): From 5524077c7262309d2c6b88c79b28877f45e9c82c Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Sat, 30 Oct 2021 17:38:31 +0200 Subject: [PATCH 05/12] Add code to find the turns/bends in a line --- gaphas/collision.py | 19 +++++++++++++------ tests/test_collision.py | 23 ++++++++++++++++++++++- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/gaphas/collision.py b/gaphas/collision.py index 2cca11e9..77ad4482 100644 --- a/gaphas/collision.py +++ b/gaphas/collision.py @@ -4,7 +4,8 @@ """ from __future__ import annotations -from operator import attrgetter +from itertools import groupby +from operator import attrgetter, itemgetter from typing import Callable, Iterable, Literal, NamedTuple, Tuple, Union from gaphas.geometry import intersect_rectangle_line @@ -63,20 +64,26 @@ def update_colliding_lines(canvas: Model, qtree: Quadtree, grid_size: int = 20) def weight(x, y, current_node): return 1 - full_path = route( + path_with_direction = route( (start_x, start_y), (end_x, end_y), weight=weight, heuristic=manhattan_distance(end_x, end_y), ) - full_path + path = turns_in_path(path_with_direction) + path # when moving items, do not update selected items # when moving a handle, selected items can be rerouted - # reduce path: find corner points to put handles # update handles on line with new points canvas.request_update(line) +def turns_in_path(path_and_dir: list[tuple[Pos, Pos]]) -> Iterable[Pos]: + for _, group in groupby(path_and_dir, key=itemgetter(1)): + *_, (position, _) = group + yield position + + # Heuristics: @@ -117,7 +124,7 @@ def route( end: Pos, weight: Weight, heuristic: Heuristic = constant_heuristic(1), -) -> list[Pos]: +) -> list[tuple[Pos, Pos]]: """Simple A* router/solver. This solver is tailored towards grids (mazes). @@ -194,7 +201,7 @@ def route( return [] -def reconstruct_path(node: Node) -> list[Pos]: +def reconstruct_path(node: Node) -> list[tuple[Pos, Pos]]: path = [] current = node while current: diff --git a/tests/test_collision.py b/tests/test_collision.py index 55268b16..8edbd7a0 100644 --- a/tests/test_collision.py +++ b/tests/test_collision.py @@ -1,4 +1,4 @@ -from gaphas.collision import colliding_lines, manhattan_distance, route +from gaphas.collision import colliding_lines, manhattan_distance, route, turns_in_path from gaphas.connections import Connections from gaphas.item import Element, Item, Line from gaphas.quadtree import Quadtree @@ -91,3 +91,24 @@ def dump_maze(maze, path): p = v print(p, end=" ") print() + + +def test_find_turns_in_path(): + path_and_dir = [ + ((0, 0), (0, 0)), + ((1, 1), (1, 1)), + ((2, 2), (1, 1)), + ((3, 3), (1, 1)), + ((3, 4), (0, 1)), + ((4, 5), (1, 1)), + ((5, 6), (1, 1)), + ((6, 6), (1, 0)), + ((7, 6), (1, 0)), + ] + + expected_path = [(0, 0), (3, 3), (3, 4), (5, 6), (7, 6)] + assert list(turns_in_path(path_and_dir)) == expected_path + + +def test_find_turns_in_empty_path(): + assert list(turns_in_path([])) == [] From edce0af2ad725f7883f50a94c831c7deff34f22a Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Sun, 31 Oct 2021 12:01:54 +0100 Subject: [PATCH 06/12] Add primitive collision avoidance for lines --- examples/demo.py | 9 +++++ gaphas/collision.py | 83 ++++++++++++++++++++++++++++------------- gaphas/segment.py | 8 ++-- tests/test_collision.py | 67 ++++++++++++++++++++++++++++++++- 4 files changed, 137 insertions(+), 30 deletions(-) diff --git a/examples/demo.py b/examples/demo.py index 5fb66aa1..95c9d81b 100755 --- a/examples/demo.py +++ b/examples/demo.py @@ -22,6 +22,7 @@ from examples.exampleitems import Box, Circle, Text from gaphas import Canvas, GtkView +from gaphas.collision import update_colliding_lines from gaphas.guide import GuidePainter from gaphas.item import Line from gaphas.painter import ( @@ -232,6 +233,14 @@ def on_delete_focused_clicked(_button): b.connect("clicked", on_delete_focused_clicked) v.add(b) + b = Gtk.Button.new_with_label("Route lines") + + def on_route_lines_clicked(_button): + update_colliding_lines(canvas, view._qtree) + + b.connect("clicked", on_route_lines_clicked) + v.add(b) + v.add(Gtk.Label.new("Export:")) b = Gtk.Button.new_with_label("Write demo.png") diff --git a/gaphas/collision.py b/gaphas/collision.py index 77ad4482..43029bd0 100644 --- a/gaphas/collision.py +++ b/gaphas/collision.py @@ -1,7 +1,13 @@ """Collision avoiding. Reroute lines when they cross elements and other lines. + +Limitations: + +- can only deal with normal lines (not orthogonal) +- uses bounding box for grid occupancy (for element and line!) """ + from __future__ import annotations from itertools import groupby @@ -11,6 +17,7 @@ from gaphas.geometry import intersect_rectangle_line from gaphas.item import Item, Line from gaphas.quadtree import Quadtree +from gaphas.segment import Segment from gaphas.view.model import Model Pos = Tuple[int, int] @@ -30,7 +37,9 @@ class Node(NamedTuple): def colliding_lines(qtree: Quadtree) -> Iterable[tuple[Line, Item]]: - lines = (item for item in qtree.items if isinstance(item, Line)) + lines = ( + item for item in qtree.items if isinstance(item, Line) and not item.orthogonal + ) for line in lines: items = qtree.find_intersect(qtree.get_bounds(line)) if not items: @@ -53,16 +62,24 @@ def colliding_lines(qtree: Quadtree) -> Iterable[tuple[Line, Item]]: break -def update_colliding_lines(canvas: Model, qtree: Quadtree, grid_size: int = 20) -> None: - for line, item in colliding_lines(qtree): +def update_colliding_lines(model: Model, qtree: Quadtree, grid_size: int = 20) -> None: + for line, _item in colliding_lines(qtree): # find start and end pos in terms of the grid - start_x = int(line.head.pos.x / grid_size) - start_y = int(line.head.pos.y / grid_size) - end_x = int(line.tail.pos.x / grid_size) - end_y = int(line.tail.pos.y / grid_size) + matrix = line.matrix_i2c + start_x, start_y = ( + int(v / grid_size) for v in matrix.transform_point(*line.head.pos) + ) + end_x, end_y = ( + int(v / grid_size) for v in matrix.transform_point(*line.tail.pos) + ) + excluded_items: set[Item] = {line} def weight(x, y, current_node): - return 1 + direction_penalty = 0 if same_direction(x, y, current_node) else 2 + occupied_penalty = ( + 5 if tile_occupied(x, y, grid_size, qtree, excluded_items) else 0 + ) + return 1 + direction_penalty + occupied_penalty path_with_direction = route( (start_x, start_y), @@ -70,12 +87,39 @@ def weight(x, y, current_node): weight=weight, heuristic=manhattan_distance(end_x, end_y), ) - path = turns_in_path(path_with_direction) - path - # when moving items, do not update selected items - # when moving a handle, selected items can be rerouted - # update handles on line with new points - canvas.request_update(line) + path = list(turns_in_path(path_with_direction)) + + segment = Segment(line, model) + while len(path) > len(line.handles()): + segment.split_segment(0) + while len(path) < len(line.handles()): + segment.merge_segment(0) + + matrix.invert() + for pos, handle in zip(path[1:-1], line.handles()[1:-1]): + cx = pos[0] * grid_size + grid_size / 2 + cy = pos[1] * grid_size + grid_size / 2 + handle.pos = matrix.transform_point(cx, cy) + + model.request_update(line) + + +def same_direction(x: int, y: int, node: Node) -> bool: + node_pos = node.position + dir_x = x - node_pos[0] + dir_y = y - node_pos[1] + node_dir = node.direction + return dir_x == node_dir[0] and dir_y == node_dir[1] + + +def tile_occupied( + x: int, y: int, grid_size: int, qtree: Quadtree, excluded_items: set[Item] +) -> bool: + items = ( + qtree.find_intersect((x * grid_size, y * grid_size, grid_size, grid_size)) + - excluded_items + ) + return bool(items) def turns_in_path(path_and_dir: list[tuple[Pos, Pos]]) -> Iterable[Pos]: @@ -108,17 +152,6 @@ def heuristic(x, y): return heuristic -# Weight: - - -def constant_weight(_node_x, _node_y, _node, cost=1): - return cost - - -def prefer_orthogonal(node_x, node_y, node, cost=5): - return 1 if node_x[0] == node.position[0] or node_y[1] == node.position[1] else cost - - def route( start: Pos, end: Pos, diff --git a/gaphas/segment.py b/gaphas/segment.py index 5966de34..53eac584 100644 --- a/gaphas/segment.py +++ b/gaphas/segment.py @@ -8,7 +8,7 @@ from gaphas.aspect import MoveType from gaphas.aspect.handlemove import HandleMove from gaphas.connector import Handle, LinePort -from gaphas.geometry import distance_line_point, distance_point_point_fast +from gaphas.geometry import Point, distance_line_point, distance_point_point_fast from gaphas.item import Item, Line, matrix_i2i from gaphas.solver import WEAK from gaphas.tool.itemtool import find_item_and_handle_at_point @@ -21,13 +21,13 @@ class Segment: def __init__(self, item, model): raise TypeError - def split_segment(self, segment, count=2): + def split_segment(self, segment: int, count: int = 2) -> None: ... - def split(self, pos): + def split(self, pos: Point) -> Handle: ... - def merge_segment(self, segment, count=2): + def merge_segment(self, segment: int, count: int = 2) -> None: ... diff --git a/tests/test_collision.py b/tests/test_collision.py index 8edbd7a0..37e69064 100644 --- a/tests/test_collision.py +++ b/tests/test_collision.py @@ -1,4 +1,14 @@ -from gaphas.collision import colliding_lines, manhattan_distance, route, turns_in_path +from gaphas.canvas import Canvas +from gaphas.collision import ( + Node, + colliding_lines, + manhattan_distance, + route, + same_direction, + tile_occupied, + turns_in_path, + update_colliding_lines, +) from gaphas.connections import Connections from gaphas.item import Element, Item, Line from gaphas.quadtree import Quadtree @@ -25,6 +35,61 @@ def test_colliding_lines(): assert (line, element) in collisions +def test_prefer_same_direction(): + node = Node(None, (0, 0), (1, 0), 0, 0) + + assert same_direction(1, 0, node) + assert not same_direction(1, 1, node) + + +def test_tile_occupied(): + connections = Connections() + qtree: Quadtree[Item, None] = Quadtree() + + line = Line(connections=connections) + line.head.pos = (0, 50) + line.tail.pos = (200, 50) + + element = Element(connections=connections) + element.height = 100 + element.width = 100 + element.matrix.translate(50, 0) + + qtree.add(line, (0, 50, 200, 50)) + qtree.add(element, (50, 0, 150, 100)) + + assert not tile_occupied(0, 0, 20, qtree, {line}) + assert tile_occupied(5, 1, 20, qtree, {line}) + + +def test_update_lines(): + canvas = Canvas() + qtree: Quadtree[Item, None] = Quadtree() + + line = Line(connections=canvas.connections) + line.head.pos = (0, 50) + line.tail.pos = (200, 50) + + element = Element(connections=canvas.connections) + element.height = 100 + element.width = 100 + element.matrix.translate(50, 0) + + qtree.add(line, (0, 50, 200, 50)) + qtree.add(element, (50, 0, 150, 100)) + + update_colliding_lines(canvas, qtree) + assert len(line.handles()) == 6 + assert [h.pos.tuple() for h in line.handles()] == [ + (0, 50), + (10, 10), + (50, -30), + (210, -30), + (250, 10), + (200, 50), + ] + + def test_maze(): _maze = [ From bc17aab5850777f8a73b7f7ec08a436c4f78b9b3 Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Sun, 31 Oct 2021 13:31:19 +0100 Subject: [PATCH 07/12] Lines can avoid obstacles in demo --- examples/demo.py | 19 +++++- gaphas/collision.py | 128 +++++++++++++++++++++++++++------------- gaphas/segment.py | 13 ++-- tests/test_collision.py | 12 ++-- 4 files changed, 119 insertions(+), 53 deletions(-) diff --git a/examples/demo.py b/examples/demo.py index 95c9d81b..10f5dbda 100755 --- a/examples/demo.py +++ b/examples/demo.py @@ -22,8 +22,12 @@ from examples.exampleitems import Box, Circle, Text from gaphas import Canvas, GtkView -from gaphas.collision import update_colliding_lines -from gaphas.guide import GuidePainter +from gaphas.aspect.handlemove import HandleMove, ItemHandleMove +from gaphas.collision import ( + CollisionAvoidingLineHandleMoveMixin, + update_colliding_lines, +) +from gaphas.guide import GuidedItemHandleMoveMixin, GuidePainter from gaphas.item import Line from gaphas.painter import ( BoundingBoxPainter, @@ -42,6 +46,7 @@ zoom_tool, ) from gaphas.tool.rubberband import RubberbandPainter, RubberbandState, rubberband_tool +from gaphas.types import Pos from gaphas.util import text_extents, text_underline # Global undo list @@ -64,6 +69,16 @@ def wrapper(): return wrapper +@HandleMove.register(Line) +class MyLineHandleMove( + CollisionAvoidingLineHandleMoveMixin, GuidedItemHandleMoveMixin, ItemHandleMove +): + """Our custom line handle move, based on guides and (experimental) + collision avoidance.""" + + pass + + class MyBox(Box): """Box with an example connection protocol.""" diff --git a/gaphas/collision.py b/gaphas/collision.py index 43029bd0..c95f0e9a 100644 --- a/gaphas/collision.py +++ b/gaphas/collision.py @@ -6,27 +6,33 @@ - can only deal with normal lines (not orthogonal) - uses bounding box for grid occupancy (for element and line!) + +THIS FEATURE IS EXPERIMENTAL! """ from __future__ import annotations +import time from itertools import groupby from operator import attrgetter, itemgetter from typing import Callable, Iterable, Literal, NamedTuple, Tuple, Union +from gaphas.decorators import g_async from gaphas.geometry import intersect_rectangle_line from gaphas.item import Item, Line from gaphas.quadtree import Quadtree from gaphas.segment import Segment +from gaphas.types import Pos +from gaphas.view.gtkview import GtkView from gaphas.view.model import Model -Pos = Tuple[int, int] +Tile = Tuple[int, int] class Node(NamedTuple): parent: object | None # type should be Node - position: Pos - direction: Pos + position: Tile + direction: Tile g: int f: int @@ -36,6 +42,33 @@ class Node(NamedTuple): Weight = Callable[[int, int, Node], Union[int, Literal["inf"]]] +def measure(func): + def _measure(*args, **kwargs): + start = time.time() + try: + return func(*args, **kwargs) + finally: + print(func.__name__, time.time() - start) + + return _measure + + +class CollisionAvoidingLineHandleMoveMixin: + view: GtkView + item: Item + + def move(self, pos: Pos) -> None: + super().move(pos) # type: ignore[misc] + self.update_line_to_avoid_collisions() + + @g_async(single=True) + def update_line_to_avoid_collisions(self): + model = self.view.model + assert model + assert isinstance(self.item, Line) + update_line_to_avoid_collisions(self.item, model, self.view._qtree) + + def colliding_lines(qtree: Quadtree) -> Iterable[tuple[Line, Item]]: lines = ( item for item in qtree.items if isinstance(item, Line) and not item.orthogonal @@ -64,44 +97,55 @@ def colliding_lines(qtree: Quadtree) -> Iterable[tuple[Line, Item]]: def update_colliding_lines(model: Model, qtree: Quadtree, grid_size: int = 20) -> None: for line, _item in colliding_lines(qtree): - # find start and end pos in terms of the grid - matrix = line.matrix_i2c - start_x, start_y = ( - int(v / grid_size) for v in matrix.transform_point(*line.head.pos) - ) - end_x, end_y = ( - int(v / grid_size) for v in matrix.transform_point(*line.tail.pos) - ) - excluded_items: set[Item] = {line} + update_line_to_avoid_collisions(line, model, qtree, grid_size) - def weight(x, y, current_node): - direction_penalty = 0 if same_direction(x, y, current_node) else 2 - occupied_penalty = ( - 5 if tile_occupied(x, y, grid_size, qtree, excluded_items) else 0 - ) - return 1 + direction_penalty + occupied_penalty - path_with_direction = route( - (start_x, start_y), - (end_x, end_y), - weight=weight, - heuristic=manhattan_distance(end_x, end_y), +def update_line_to_avoid_collisions( + line: Line, model: Model, qtree: Quadtree, grid_size: int = 20 +) -> None: + # find start and end pos in terms of the grid + matrix = line.matrix_i2c + start_x, start_y = ( + int(v / grid_size) for v in matrix.transform_point(*line.head.pos) + ) + end_x, end_y = (int(v / grid_size) for v in matrix.transform_point(*line.tail.pos)) + excluded_items: set[Item] = {line} + start_end_tiles = ((start_x, start_y), (end_x, end_y)) + + def weight(x, y, current_node): + direction_penalty = 0 if same_direction(x, y, current_node) else 1 + diagonal_penalty = 0 if prefer_orthogonal(x, y, current_node) else 1 + occupied_penalty = ( + 0 + if (x, y) in start_end_tiles + or not tile_occupied(x, y, grid_size, qtree, excluded_items) + else 5 ) - path = list(turns_in_path(path_with_direction)) + return 1 + direction_penalty + diagonal_penalty + occupied_penalty - segment = Segment(line, model) - while len(path) > len(line.handles()): - segment.split_segment(0) - while len(path) < len(line.handles()): - segment.merge_segment(0) + path_with_direction = route( + (start_x, start_y), + (end_x, end_y), + weight=weight, + heuristic=manhattan_distance(end_x, end_y), + ) + if not path_with_direction: + return + path = list(turns_in_path(path_with_direction)) + + segment = Segment(line, model) + while len(path) > len(line.handles()): + segment.split_segment(0) + while 2 < len(path) < len(line.handles()): + segment.merge_segment(0) - matrix.invert() - for pos, handle in zip(path[1:-1], line.handles()[1:-1]): - cx = pos[0] * grid_size + grid_size / 2 - cy = pos[1] * grid_size + grid_size / 2 - handle.pos = matrix.transform_point(cx, cy) + imatrix = matrix.inverse() + for pos, handle in zip(path[1:-1], line.handles()[1:-1]): + cx = pos[0] * grid_size + grid_size / 2 + cy = pos[1] * grid_size + grid_size / 2 + handle.pos = imatrix.transform_point(cx, cy) - model.request_update(line) + model.request_update(line) def same_direction(x: int, y: int, node: Node) -> bool: @@ -112,6 +156,10 @@ def same_direction(x: int, y: int, node: Node) -> bool: return dir_x == node_dir[0] and dir_y == node_dir[1] +def prefer_orthogonal(x: int, y: int, node: Node) -> bool: + return x == node.position[0] or y == node.position[1] + + def tile_occupied( x: int, y: int, grid_size: int, qtree: Quadtree, excluded_items: set[Item] ) -> bool: @@ -122,7 +170,7 @@ def tile_occupied( return bool(items) -def turns_in_path(path_and_dir: list[tuple[Pos, Pos]]) -> Iterable[Pos]: +def turns_in_path(path_and_dir: list[tuple[Tile, Tile]]) -> Iterable[Tile]: for _, group in groupby(path_and_dir, key=itemgetter(1)): *_, (position, _) = group yield position @@ -153,11 +201,11 @@ def heuristic(x, y): def route( - start: Pos, - end: Pos, + start: Tile, + end: Tile, weight: Weight, heuristic: Heuristic = constant_heuristic(1), -) -> list[tuple[Pos, Pos]]: +) -> list[tuple[Tile, Tile]]: """Simple A* router/solver. This solver is tailored towards grids (mazes). @@ -234,7 +282,7 @@ def route( return [] -def reconstruct_path(node: Node) -> list[tuple[Pos, Pos]]: +def reconstruct_path(node: Node) -> list[tuple[Tile, Tile]]: path = [] current = node while current: diff --git a/gaphas/segment.py b/gaphas/segment.py index 53eac584..c8f3e7cc 100644 --- a/gaphas/segment.py +++ b/gaphas/segment.py @@ -1,13 +1,13 @@ """Allow for easily adding segments to lines.""" from functools import singledispatch -from typing import Optional +from typing import Optional, Sequence from cairo import ANTIALIAS_NONE from gi.repository import Gtk from gaphas.aspect import MoveType from gaphas.aspect.handlemove import HandleMove -from gaphas.connector import Handle, LinePort +from gaphas.connector import Handle, LinePort, Port from gaphas.geometry import Point, distance_line_point, distance_point_point_fast from gaphas.item import Item, Line, matrix_i2i from gaphas.solver import WEAK @@ -21,13 +21,17 @@ class Segment: def __init__(self, item, model): raise TypeError - def split_segment(self, segment: int, count: int = 2) -> None: + def split_segment( + self, segment: int, count: int = 2 + ) -> tuple[Sequence[Handle], Sequence[Port]]: ... def split(self, pos: Point) -> Handle: ... - def merge_segment(self, segment: int, count: int = 2) -> None: + def merge_segment( + self, segment: int, count: int = 2 + ) -> tuple[Sequence[Handle], Sequence[Port]]: ... @@ -52,7 +56,6 @@ def split(self, pos): def split_segment(self, segment, count=2): """Split one item segment into ``count`` equal pieces. - def split_segment(self, segment, count=2): Two lists are returned - list of created handles diff --git a/tests/test_collision.py b/tests/test_collision.py index 37e69064..fc32fab2 100644 --- a/tests/test_collision.py +++ b/tests/test_collision.py @@ -81,12 +81,12 @@ def test_update_lines(): update_colliding_lines(canvas, qtree) assert len(line.handles()) == 6 assert [h.pos.tuple() for h in line.handles()] == [ - (0, 50), - (10, 10), - (50, -30), - (210, -30), - (250, 10), - (200, 50), + (0.0, 50.0), + (10.0, 10.0), + (50.0, -30.0), + (230.0, -30.0), + (230.0, 30.0), + (200.0, 50.0), ] From a4982520d18960bab7866df2cdbeaa31fd0e39f6 Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Sun, 31 Oct 2021 13:35:43 +0100 Subject: [PATCH 08/12] Avoid use of Matrix.invert() It's in place, and can cause for funky behavior. --- gaphas/aspect/connector.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gaphas/aspect/connector.py b/gaphas/aspect/connector.py index 4656b58c..d9d5c5c8 100644 --- a/gaphas/aspect/connector.py +++ b/gaphas/aspect/connector.py @@ -53,8 +53,7 @@ def glue(self, sink: ConnectionSinkType) -> Optional[Pos]: ) glue_pos = sink.glue(pos, secondary_pos) if glue_pos and self.allow(sink): - matrix.invert() - new_pos = matrix.transform_point(*glue_pos) + new_pos = matrix.inverse().transform_point(*glue_pos) handle.pos = new_pos return new_pos return None From 32c2da1e0fdb71ed0b2f901be42e88766f3c535c Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Sun, 31 Oct 2021 14:53:08 +0100 Subject: [PATCH 09/12] Fix typing for Py 3.8 --- gaphas/segment.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gaphas/segment.py b/gaphas/segment.py index c8f3e7cc..7e82c695 100644 --- a/gaphas/segment.py +++ b/gaphas/segment.py @@ -1,6 +1,6 @@ """Allow for easily adding segments to lines.""" from functools import singledispatch -from typing import Optional, Sequence +from typing import Optional, Sequence, Tuple from cairo import ANTIALIAS_NONE from gi.repository import Gtk @@ -23,7 +23,7 @@ def __init__(self, item, model): def split_segment( self, segment: int, count: int = 2 - ) -> tuple[Sequence[Handle], Sequence[Port]]: + ) -> Tuple[Sequence[Handle], Sequence[Port]]: ... def split(self, pos: Point) -> Handle: @@ -31,7 +31,7 @@ def split(self, pos: Point) -> Handle: def merge_segment( self, segment: int, count: int = 2 - ) -> tuple[Sequence[Handle], Sequence[Port]]: + ) -> Tuple[Sequence[Handle], Sequence[Port]]: ... From 27e446793c1682a2683b6c85f43542dd4c7ffa10 Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Sun, 31 Oct 2021 15:44:43 +0100 Subject: [PATCH 10/12] Only do routing when line ends are moved --- gaphas/collision.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gaphas/collision.py b/gaphas/collision.py index c95f0e9a..6b36cbcf 100644 --- a/gaphas/collision.py +++ b/gaphas/collision.py @@ -17,6 +17,7 @@ from operator import attrgetter, itemgetter from typing import Callable, Iterable, Literal, NamedTuple, Tuple, Union +from gaphas.connections import Handle from gaphas.decorators import g_async from gaphas.geometry import intersect_rectangle_line from gaphas.item import Item, Line @@ -56,10 +57,14 @@ def _measure(*args, **kwargs): class CollisionAvoidingLineHandleMoveMixin: view: GtkView item: Item + handle: Handle def move(self, pos: Pos) -> None: super().move(pos) # type: ignore[misc] - self.update_line_to_avoid_collisions() + line = self.item + assert isinstance(line, Line) + if self.handle in (line.head, line.tail): + self.update_line_to_avoid_collisions() @g_async(single=True) def update_line_to_avoid_collisions(self): From 8fa2d5ef43114453b49e2bc2f2e036fd34757234 Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Tue, 9 Nov 2021 21:49:31 +0100 Subject: [PATCH 11/12] Export segments as tuple That makes comparing not error. --- gaphas/item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gaphas/item.py b/gaphas/item.py index 44e10348..b0b6401e 100644 --- a/gaphas/item.py +++ b/gaphas/item.py @@ -344,7 +344,7 @@ def horizontal(self, horizontal: bool) -> None: @property def segments(self): hpos = [h.pos for h in self._handles] - return zip(hpos, hpos[1:]) + return zip(tuple(hpos), tuple(hpos[1:])) def insert_handle(self, index: int, handle: Handle) -> None: self._handles.insert(index, handle) From c7181c01b42448b0cce398be6ac25cabc0ba3827 Mon Sep 17 00:00:00 2001 From: Arjan Molenaar Date: Tue, 9 Nov 2021 21:58:57 +0100 Subject: [PATCH 12/12] Make auto-router work for orthogonal lines --- gaphas/collision.py | 29 ++++++++++++++++----- tests/test_collision.py | 56 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/gaphas/collision.py b/gaphas/collision.py index 6b36cbcf..58407ad7 100644 --- a/gaphas/collision.py +++ b/gaphas/collision.py @@ -116,6 +116,7 @@ def update_line_to_avoid_collisions( end_x, end_y = (int(v / grid_size) for v in matrix.transform_point(*line.tail.pos)) excluded_items: set[Item] = {line} start_end_tiles = ((start_x, start_y), (end_x, end_y)) + orthogonal = line.orthogonal def weight(x, y, current_node): direction_penalty = 0 if same_direction(x, y, current_node) else 1 @@ -133,15 +134,18 @@ def weight(x, y, current_node): (end_x, end_y), weight=weight, heuristic=manhattan_distance(end_x, end_y), + orthogonal=orthogonal, ) - if not path_with_direction: + if len(path_with_direction) < 2: return path = list(turns_in_path(path_with_direction)) + min_handles = 3 if orthogonal else 2 + segment = Segment(line, model) while len(path) > len(line.handles()): segment.split_segment(0) - while 2 < len(path) < len(line.handles()): + while min_handles < len(path) < len(line.handles()): segment.merge_segment(0) imatrix = matrix.inverse() @@ -150,6 +154,14 @@ def weight(x, y, current_node): cy = pos[1] * grid_size + grid_size / 2 handle.pos = imatrix.transform_point(cx, cy) + if orthogonal: + _, dir = path_with_direction[1] + line.horizontal = dir == (1, 0) + if line.horizontal: + line.handles()[1].pos.y = line.handles()[0].pos.y + else: + line.handles()[1].pos.x = line.handles()[0].pos.x + model.request_update(line) @@ -210,6 +222,7 @@ def route( end: Tile, weight: Weight, heuristic: Heuristic = constant_heuristic(1), + orthogonal: bool = False, ) -> list[tuple[Tile, Tile]]: """Simple A* router/solver. @@ -240,12 +253,16 @@ def route( (0, 1), (-1, 0), (1, 0), - (-1, -1), - (-1, 1), - (1, -1), - (1, 1), ] + if not orthogonal: + directions += [ + (-1, -1), + (-1, 1), + (1, -1), + (1, 1), + ] + while open_nodes: current_node = min(open_nodes, key=f) diff --git a/tests/test_collision.py b/tests/test_collision.py index fc32fab2..75f44aa6 100644 --- a/tests/test_collision.py +++ b/tests/test_collision.py @@ -8,8 +8,10 @@ tile_occupied, turns_in_path, update_colliding_lines, + update_line_to_avoid_collisions, ) from gaphas.connections import Connections +from gaphas.connector import Handle from gaphas.item import Element, Item, Line from gaphas.quadtree import Quadtree @@ -62,6 +64,60 @@ def test_tile_occupied(): assert tile_occupied(5, 1, 20, qtree, {line}) +def test_solve_orthogonal_line(): + canvas = Canvas() + qtree: Quadtree[Item, None] = Quadtree() + + line = Line(connections=canvas.connections) + line.head.pos = (0, 0) + line.tail.pos = (200, 200) + line.insert_handle(1, Handle((100, 100))) + line.orthogonal = True + + element = Element(connections=canvas.connections) + element.height = 150 + element.width = 150 + element.matrix.translate(50, 0) + + qtree.add(line, (0, 0, 200, 200)) + qtree.add(element, (50, 0, 200, 150)) + + update_line_to_avoid_collisions(line, canvas, qtree) + handles = line.handles() + + assert not line.horizontal + assert handles[0].pos.tuple() == (0, 0) + assert handles[1].pos.tuple() == (0, 210) + assert handles[2].pos.tuple() == (200, 200) + + +def test_solve_horizontal_orthogonal_line(): + canvas = Canvas() + qtree: Quadtree[Item, None] = Quadtree() + + line = Line(connections=canvas.connections) + line.head.pos = (0, 0) + line.tail.pos = (200, 200) + line.insert_handle(1, Handle((100, 100))) + line.orthogonal = True + + element = Element(connections=canvas.connections) + element.height = 150 + element.width = 150 + element.matrix.translate(0, 50) + + qtree.add(line, (0, 0, 200, 200)) + qtree.add(element, (0, 50, 150, 200)) + + update_line_to_avoid_collisions(line, canvas, qtree) + handles = line.handles() + + assert line.horizontal + assert handles[0].pos.tuple() == (0, 0) + assert handles[1].pos.tuple() == (210, 0) + assert handles[2].pos.tuple() == (200, 200) + + def test_update_lines(): canvas = Canvas() qtree: Quadtree[Item, None] = Quadtree()