voidmesh 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voidmesh/__init__.py +7 -0
- voidmesh/canvas.py +439 -0
- voidmesh/demo.py +23 -0
- voidmesh/layout.py +92 -0
- voidmesh/models.py +41 -0
- voidmesh/physics.py +99 -0
- voidmesh/py.typed +0 -0
- voidmesh/renderer.py +545 -0
- voidmesh-0.1.0.dist-info/METADATA +115 -0
- voidmesh-0.1.0.dist-info/RECORD +13 -0
- voidmesh-0.1.0.dist-info/WHEEL +4 -0
- voidmesh-0.1.0.dist-info/entry_points.txt +3 -0
- voidmesh-0.1.0.dist-info/licenses/LICENSE +21 -0
voidmesh/physics.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The dependency-free force simulation used by the renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from .models import Link, Node
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Physics:
|
|
12
|
+
"""A stable force-directed layout with springs and center gravity."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
center_force: float = 0.12,
|
|
18
|
+
repel_force: float = 4.0,
|
|
19
|
+
link_force: float = 0.35,
|
|
20
|
+
link_distance: float = 150.0,
|
|
21
|
+
damping: float = 0.9,
|
|
22
|
+
max_speed: float = 520.0,
|
|
23
|
+
gravity: float | None = None,
|
|
24
|
+
repulsion: float | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
if gravity is not None:
|
|
27
|
+
center_force = gravity / 0.12
|
|
28
|
+
if repulsion is not None:
|
|
29
|
+
repel_force = repulsion / 900.0
|
|
30
|
+
self.center_force = center_force
|
|
31
|
+
self.repel_force = repel_force
|
|
32
|
+
self.link_force = link_force
|
|
33
|
+
self.link_distance = link_distance
|
|
34
|
+
self.damping = damping
|
|
35
|
+
self.max_speed = max_speed
|
|
36
|
+
|
|
37
|
+
def step(
|
|
38
|
+
self,
|
|
39
|
+
nodes: dict[str, Node],
|
|
40
|
+
links: Iterable[Link],
|
|
41
|
+
dt: float,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Advance the layout by ``dt`` seconds, mutating node positions."""
|
|
44
|
+
if not nodes or dt <= 0:
|
|
45
|
+
return
|
|
46
|
+
dt = min(dt, 1 / 20)
|
|
47
|
+
forces = {node_id: [0.0, 0.0] for node_id in nodes}
|
|
48
|
+
node_list = list(nodes.values())
|
|
49
|
+
|
|
50
|
+
for index, left in enumerate(node_list):
|
|
51
|
+
for right in node_list[index + 1 :]:
|
|
52
|
+
dx = right.x - left.x
|
|
53
|
+
dy = right.y - left.y
|
|
54
|
+
distance_sq = dx * dx + dy * dy
|
|
55
|
+
if distance_sq < 1.0:
|
|
56
|
+
dx, dy, distance_sq = 1.0, 0.5, 1.25
|
|
57
|
+
distance = math.sqrt(distance_sq)
|
|
58
|
+
magnitude = (self.repel_force * 900.0) / distance_sq
|
|
59
|
+
fx = magnitude * dx / distance
|
|
60
|
+
fy = magnitude * dy / distance
|
|
61
|
+
forces[left.id][0] -= fx
|
|
62
|
+
forces[left.id][1] -= fy
|
|
63
|
+
forces[right.id][0] += fx
|
|
64
|
+
forces[right.id][1] += fy
|
|
65
|
+
|
|
66
|
+
for link in links:
|
|
67
|
+
source = nodes.get(link.source)
|
|
68
|
+
target = nodes.get(link.target)
|
|
69
|
+
if source is None or target is None:
|
|
70
|
+
continue
|
|
71
|
+
dx = target.x - source.x
|
|
72
|
+
dy = target.y - source.y
|
|
73
|
+
distance = max(math.hypot(dx, dy), 0.001)
|
|
74
|
+
preferred_length = link.length * (self.link_distance / 115.0)
|
|
75
|
+
magnitude = link.elasticity * self.link_force * 4.0 * (distance - preferred_length)
|
|
76
|
+
fx = magnitude * dx / distance
|
|
77
|
+
fy = magnitude * dy / distance
|
|
78
|
+
forces[source.id][0] += fx
|
|
79
|
+
forces[source.id][1] += fy
|
|
80
|
+
forces[target.id][0] -= fx
|
|
81
|
+
forces[target.id][1] -= fy
|
|
82
|
+
|
|
83
|
+
for node in node_list:
|
|
84
|
+
if node.fixed:
|
|
85
|
+
node.vx = node.vy = 0.0
|
|
86
|
+
continue
|
|
87
|
+
fx, fy = forces[node.id]
|
|
88
|
+
gravity = self.center_force * 0.12
|
|
89
|
+
fx -= node.x * gravity
|
|
90
|
+
fy -= node.y * gravity
|
|
91
|
+
node.vx = (node.vx + fx * dt * 60.0) * self.damping
|
|
92
|
+
node.vy = (node.vy + fy * dt * 60.0) * self.damping
|
|
93
|
+
speed = math.hypot(node.vx, node.vy)
|
|
94
|
+
if speed > self.max_speed:
|
|
95
|
+
scale = self.max_speed / speed
|
|
96
|
+
node.vx *= scale
|
|
97
|
+
node.vy *= scale
|
|
98
|
+
node.x += node.vx * dt
|
|
99
|
+
node.y += node.vy * dt
|
voidmesh/py.typed
ADDED
|
File without changes
|
voidmesh/renderer.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""Pygame renderer and interaction layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import pygame
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .canvas import Canvas
|
|
12
|
+
from .models import Color, Node
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class SliderSpec:
|
|
17
|
+
label: str
|
|
18
|
+
attribute: str
|
|
19
|
+
minimum: float
|
|
20
|
+
maximum: float
|
|
21
|
+
decimals: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
SLIDERS = (
|
|
25
|
+
SliderSpec("Center force", "center_force", 0.0, 1.0, 2),
|
|
26
|
+
SliderSpec("Repel force", "repel_force", 0.0, 20.0, 2),
|
|
27
|
+
SliderSpec("Link force", "link_force", 0.0, 2.0, 2),
|
|
28
|
+
SliderSpec("Link distance", "link_distance", 30.0, 400.0, 0),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ControlPanel:
|
|
33
|
+
"""Small immediate-mode controls for the live simulation."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, canvas: Canvas) -> None:
|
|
36
|
+
self.canvas = canvas
|
|
37
|
+
self.collapsed = False
|
|
38
|
+
self.active_slider: int | None = None
|
|
39
|
+
self.dragging = False
|
|
40
|
+
self.position = pygame.Vector2(14, 14)
|
|
41
|
+
self.drag_offset = pygame.Vector2()
|
|
42
|
+
self._font_key = 0
|
|
43
|
+
self._font: pygame.font.Font | None = None
|
|
44
|
+
self._small_font: pygame.font.Font | None = None
|
|
45
|
+
|
|
46
|
+
def bounds(self, screen: pygame.Surface) -> pygame.Rect:
|
|
47
|
+
scale = self.scale(screen)
|
|
48
|
+
width = round(280 * scale)
|
|
49
|
+
height = round((44 if self.collapsed else 398) * scale)
|
|
50
|
+
x = max(0, min(round(self.position.x), screen.get_width() - width))
|
|
51
|
+
y = max(0, min(round(self.position.y), screen.get_height() - height))
|
|
52
|
+
self.position.update(x, y)
|
|
53
|
+
return pygame.Rect(x, y, width, height)
|
|
54
|
+
|
|
55
|
+
def scale(self, screen: pygame.Surface) -> float:
|
|
56
|
+
if self.canvas.ui_scale is not None:
|
|
57
|
+
requested = self.canvas.ui_scale
|
|
58
|
+
else:
|
|
59
|
+
requested = min(
|
|
60
|
+
screen.get_width() / self.canvas.width,
|
|
61
|
+
screen.get_height() / self.canvas.height,
|
|
62
|
+
)
|
|
63
|
+
requested = max(0.75, min(2.0, requested))
|
|
64
|
+
return min(requested, (screen.get_width() - 12) / 280)
|
|
65
|
+
|
|
66
|
+
def contains(self, point: tuple[int, int], screen: pygame.Surface) -> bool:
|
|
67
|
+
return self.bounds(screen).collidepoint(point)
|
|
68
|
+
|
|
69
|
+
def handle(self, event: pygame.event.Event, screen: pygame.Surface) -> bool:
|
|
70
|
+
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
|
71
|
+
if not self.contains(event.pos, screen):
|
|
72
|
+
return False
|
|
73
|
+
if self._chevron_rect(screen).collidepoint(event.pos):
|
|
74
|
+
self.collapsed = not self.collapsed
|
|
75
|
+
self.active_slider = None
|
|
76
|
+
return True
|
|
77
|
+
if self._header_rect(screen).collidepoint(event.pos):
|
|
78
|
+
self.dragging = True
|
|
79
|
+
self.drag_offset = pygame.Vector2(event.pos) - self.position
|
|
80
|
+
return True
|
|
81
|
+
if self.collapsed:
|
|
82
|
+
return True
|
|
83
|
+
for index in range(len(SLIDERS)):
|
|
84
|
+
if self._slider_hit_rect(index, screen).collidepoint(event.pos):
|
|
85
|
+
self.active_slider = index
|
|
86
|
+
self._set_slider(index, event.pos[0], screen)
|
|
87
|
+
return True
|
|
88
|
+
if self._force_view_rect(screen).collidepoint(event.pos):
|
|
89
|
+
self.canvas.set_layout("force")
|
|
90
|
+
return True
|
|
91
|
+
if self._tree_view_rect(screen).collidepoint(event.pos):
|
|
92
|
+
self.canvas.set_layout("tree")
|
|
93
|
+
return True
|
|
94
|
+
if self._grid_rect(screen).collidepoint(event.pos):
|
|
95
|
+
self.canvas.toggle_grid()
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
if event.type == pygame.MOUSEMOTION:
|
|
99
|
+
if self.dragging:
|
|
100
|
+
bounds = self.bounds(screen)
|
|
101
|
+
requested = pygame.Vector2(event.pos) - self.drag_offset
|
|
102
|
+
requested.x = max(0, min(requested.x, screen.get_width() - bounds.width))
|
|
103
|
+
requested.y = max(0, min(requested.y, screen.get_height() - bounds.height))
|
|
104
|
+
self.position = requested
|
|
105
|
+
return True
|
|
106
|
+
if self.active_slider is not None:
|
|
107
|
+
self._set_slider(self.active_slider, event.pos[0], screen)
|
|
108
|
+
return True
|
|
109
|
+
if event.type == pygame.MOUSEBUTTONUP and event.button == 1:
|
|
110
|
+
was_active = self.active_slider is not None or self.dragging
|
|
111
|
+
self.active_slider = None
|
|
112
|
+
self.dragging = False
|
|
113
|
+
return was_active or self.contains(event.pos, screen)
|
|
114
|
+
if event.type == pygame.MOUSEWHEEL:
|
|
115
|
+
return self.contains(pygame.mouse.get_pos(), screen)
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def draw(self, screen: pygame.Surface) -> None:
|
|
119
|
+
bounds = self.bounds(screen)
|
|
120
|
+
scale = self.scale(screen)
|
|
121
|
+
font, small_font = self._fonts(scale)
|
|
122
|
+
surface = pygame.Surface(bounds.size, pygame.SRCALPHA)
|
|
123
|
+
radius = round(8 * scale)
|
|
124
|
+
pygame.draw.rect(surface, (24, 27, 36, 238), surface.get_rect(), border_radius=radius)
|
|
125
|
+
pygame.draw.rect(
|
|
126
|
+
surface, (62, 68, 82, 190), surface.get_rect(), 1, border_radius=radius
|
|
127
|
+
)
|
|
128
|
+
icon_center = pygame.Vector2(round(19 * scale), round(21 * scale))
|
|
129
|
+
icon_size = max(3, round(5 * scale))
|
|
130
|
+
if self.collapsed:
|
|
131
|
+
points = [
|
|
132
|
+
(icon_center.x - icon_size / 2, icon_center.y - icon_size),
|
|
133
|
+
(icon_center.x + icon_size / 2, icon_center.y),
|
|
134
|
+
(icon_center.x - icon_size / 2, icon_center.y + icon_size),
|
|
135
|
+
]
|
|
136
|
+
else:
|
|
137
|
+
points = [
|
|
138
|
+
(icon_center.x - icon_size, icon_center.y - icon_size / 2),
|
|
139
|
+
(icon_center.x, icon_center.y + icon_size / 2),
|
|
140
|
+
(icon_center.x + icon_size, icon_center.y - icon_size / 2),
|
|
141
|
+
]
|
|
142
|
+
pygame.draw.lines(surface, (148, 163, 184), False, points, max(1, round(2 * scale)))
|
|
143
|
+
title = font.render("Controls", True, (226, 232, 240))
|
|
144
|
+
surface.blit(title, (round(34 * scale), round(11 * scale)))
|
|
145
|
+
|
|
146
|
+
if not self.collapsed:
|
|
147
|
+
for index, spec in enumerate(SLIDERS):
|
|
148
|
+
top = round((51 + index * 65) * scale)
|
|
149
|
+
value = float(getattr(self.canvas.physics, spec.attribute))
|
|
150
|
+
label = font.render(spec.label, True, (203, 213, 225))
|
|
151
|
+
shown = f"{value:.{spec.decimals}f}"
|
|
152
|
+
value_label = small_font.render(shown, True, (148, 163, 184))
|
|
153
|
+
surface.blit(label, (round(13 * scale), top))
|
|
154
|
+
surface.blit(value_label, (round(13 * scale), top + round(30 * scale)))
|
|
155
|
+
|
|
156
|
+
track_left = round(61 * scale)
|
|
157
|
+
track_right = round(262 * scale)
|
|
158
|
+
track_y = top + round(36 * scale)
|
|
159
|
+
ratio = (value - spec.minimum) / (spec.maximum - spec.minimum)
|
|
160
|
+
knob_x = round(track_left + ratio * (track_right - track_left))
|
|
161
|
+
pygame.draw.line(
|
|
162
|
+
surface,
|
|
163
|
+
(64, 68, 78),
|
|
164
|
+
(track_left, track_y),
|
|
165
|
+
(track_right, track_y),
|
|
166
|
+
max(2, round(5 * scale)),
|
|
167
|
+
)
|
|
168
|
+
pygame.draw.line(
|
|
169
|
+
surface,
|
|
170
|
+
(139, 92, 246),
|
|
171
|
+
(track_left, track_y),
|
|
172
|
+
(knob_x, track_y),
|
|
173
|
+
max(2, round(5 * scale)),
|
|
174
|
+
)
|
|
175
|
+
pygame.draw.circle(
|
|
176
|
+
surface, (248, 250, 252), (knob_x, track_y), round(10 * scale)
|
|
177
|
+
)
|
|
178
|
+
pygame.draw.circle(
|
|
179
|
+
surface,
|
|
180
|
+
(203, 213, 225),
|
|
181
|
+
(knob_x, track_y),
|
|
182
|
+
round(10 * scale),
|
|
183
|
+
max(1, round(scale)),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
view_y = round(318 * scale)
|
|
187
|
+
label = font.render("View", True, (203, 213, 225))
|
|
188
|
+
surface.blit(label, (round(13 * scale), view_y + round(4 * scale)))
|
|
189
|
+
force_button = pygame.Rect(
|
|
190
|
+
round(105 * scale), view_y, round(74 * scale), round(27 * scale)
|
|
191
|
+
)
|
|
192
|
+
tree_button = pygame.Rect(
|
|
193
|
+
round(182 * scale), view_y, round(82 * scale), round(27 * scale)
|
|
194
|
+
)
|
|
195
|
+
self._draw_view_button(surface, font, force_button, "Force", "force")
|
|
196
|
+
self._draw_view_button(surface, font, tree_button, "Tree", "tree")
|
|
197
|
+
|
|
198
|
+
grid_y = round(362 * scale)
|
|
199
|
+
label = font.render("Background grid", True, (203, 213, 225))
|
|
200
|
+
surface.blit(label, (round(13 * scale), grid_y))
|
|
201
|
+
toggle = pygame.Rect(
|
|
202
|
+
round(227 * scale),
|
|
203
|
+
grid_y - round(scale),
|
|
204
|
+
round(37 * scale),
|
|
205
|
+
round(21 * scale),
|
|
206
|
+
)
|
|
207
|
+
toggle_color = (139, 92, 246) if self.canvas.grid else (64, 68, 78)
|
|
208
|
+
pygame.draw.rect(surface, toggle_color, toggle, border_radius=round(11 * scale))
|
|
209
|
+
offset = round(10 * scale)
|
|
210
|
+
knob_x = toggle.right - offset if self.canvas.grid else toggle.left + offset
|
|
211
|
+
pygame.draw.circle(
|
|
212
|
+
surface, (248, 250, 252), (knob_x, toggle.centery), round(8 * scale)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
screen.blit(surface, bounds.topleft)
|
|
216
|
+
|
|
217
|
+
def _draw_view_button(
|
|
218
|
+
self,
|
|
219
|
+
surface: pygame.Surface,
|
|
220
|
+
font: pygame.font.Font,
|
|
221
|
+
rect: pygame.Rect,
|
|
222
|
+
label: str,
|
|
223
|
+
layout: str,
|
|
224
|
+
) -> None:
|
|
225
|
+
selected = self.canvas.layout == layout
|
|
226
|
+
color = (139, 92, 246) if selected else (48, 53, 65)
|
|
227
|
+
pygame.draw.rect(surface, color, rect, border_radius=max(3, rect.height // 4))
|
|
228
|
+
text_color = (248, 250, 252) if selected else (148, 163, 184)
|
|
229
|
+
text = font.render(label, True, text_color)
|
|
230
|
+
surface.blit(text, text.get_rect(center=rect.center))
|
|
231
|
+
|
|
232
|
+
def _fonts(self, scale: float) -> tuple[pygame.font.Font, pygame.font.Font]:
|
|
233
|
+
key = round(scale * 100)
|
|
234
|
+
if self._font is None or self._small_font is None or key != self._font_key:
|
|
235
|
+
self._font_key = key
|
|
236
|
+
self._font = pygame.font.SysFont("sans", max(10, round(15 * scale)))
|
|
237
|
+
self._small_font = pygame.font.SysFont("sans", max(8, round(12 * scale)))
|
|
238
|
+
return self._font, self._small_font
|
|
239
|
+
|
|
240
|
+
def _set_slider(self, index: int, mouse_x: int, screen: pygame.Surface) -> None:
|
|
241
|
+
spec = SLIDERS[index]
|
|
242
|
+
track = self._slider_track(index, screen)
|
|
243
|
+
ratio = max(0.0, min(1.0, (mouse_x - track.left) / track.width))
|
|
244
|
+
value = spec.minimum + ratio * (spec.maximum - spec.minimum)
|
|
245
|
+
setattr(self.canvas.physics, spec.attribute, value)
|
|
246
|
+
|
|
247
|
+
def _header_rect(self, screen: pygame.Surface) -> pygame.Rect:
|
|
248
|
+
bounds = self.bounds(screen)
|
|
249
|
+
return pygame.Rect(bounds.x, bounds.y, bounds.width, round(42 * self.scale(screen)))
|
|
250
|
+
|
|
251
|
+
def _chevron_rect(self, screen: pygame.Surface) -> pygame.Rect:
|
|
252
|
+
header = self._header_rect(screen)
|
|
253
|
+
return pygame.Rect(header.x, header.y, round(38 * self.scale(screen)), header.height)
|
|
254
|
+
|
|
255
|
+
def _slider_track(self, index: int, screen: pygame.Surface) -> pygame.Rect:
|
|
256
|
+
bounds = self.bounds(screen)
|
|
257
|
+
scale = self.scale(screen)
|
|
258
|
+
return pygame.Rect(
|
|
259
|
+
bounds.x + round(61 * scale),
|
|
260
|
+
bounds.y + round((87 + index * 65) * scale),
|
|
261
|
+
round(201 * scale),
|
|
262
|
+
1,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _slider_hit_rect(self, index: int, screen: pygame.Surface) -> pygame.Rect:
|
|
266
|
+
scale = self.scale(screen)
|
|
267
|
+
return self._slider_track(index, screen).inflate(round(16 * scale), round(26 * scale))
|
|
268
|
+
|
|
269
|
+
def _grid_rect(self, screen: pygame.Surface) -> pygame.Rect:
|
|
270
|
+
bounds = self.bounds(screen)
|
|
271
|
+
scale = self.scale(screen)
|
|
272
|
+
return pygame.Rect(
|
|
273
|
+
bounds.x, bounds.y + round(353 * scale), bounds.width, round(39 * scale)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _force_view_rect(self, screen: pygame.Surface) -> pygame.Rect:
|
|
277
|
+
bounds = self.bounds(screen)
|
|
278
|
+
scale = self.scale(screen)
|
|
279
|
+
return pygame.Rect(
|
|
280
|
+
bounds.x + round(105 * scale),
|
|
281
|
+
bounds.y + round(318 * scale),
|
|
282
|
+
round(74 * scale),
|
|
283
|
+
round(27 * scale),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def _tree_view_rect(self, screen: pygame.Surface) -> pygame.Rect:
|
|
287
|
+
bounds = self.bounds(screen)
|
|
288
|
+
scale = self.scale(screen)
|
|
289
|
+
return pygame.Rect(
|
|
290
|
+
bounds.x + round(182 * scale),
|
|
291
|
+
bounds.y + round(318 * scale),
|
|
292
|
+
round(82 * scale),
|
|
293
|
+
round(27 * scale),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Renderer:
|
|
298
|
+
def __init__(self, canvas: Canvas) -> None:
|
|
299
|
+
self.canvas = canvas
|
|
300
|
+
self.running = False
|
|
301
|
+
self.zoom = 1.0
|
|
302
|
+
self.pan = pygame.Vector2()
|
|
303
|
+
self.dragged: Node | None = None
|
|
304
|
+
self.drag_was_fixed = False
|
|
305
|
+
self.panning = False
|
|
306
|
+
self.panel = ControlPanel(canvas)
|
|
307
|
+
self._font_cache: dict[tuple[str, int], pygame.font.Font] = {}
|
|
308
|
+
|
|
309
|
+
def run(self) -> None:
|
|
310
|
+
# Initializing all pygame modules also opens audio, which this visual-only
|
|
311
|
+
# library does not need and which can fail on otherwise valid desktops.
|
|
312
|
+
pygame.display.init()
|
|
313
|
+
pygame.font.init()
|
|
314
|
+
pygame.display.set_caption(self.canvas.title)
|
|
315
|
+
screen = pygame.display.set_mode(
|
|
316
|
+
(self.canvas.width, self.canvas.height), pygame.RESIZABLE
|
|
317
|
+
)
|
|
318
|
+
clock = pygame.time.Clock()
|
|
319
|
+
self.running = True
|
|
320
|
+
self.canvas._ready.set()
|
|
321
|
+
try:
|
|
322
|
+
while self.running:
|
|
323
|
+
dt = clock.tick(self.canvas.fps) / 1000.0
|
|
324
|
+
self._events(screen)
|
|
325
|
+
with self.canvas._locked_graph() as (nodes, links):
|
|
326
|
+
if self.canvas.layout == "force":
|
|
327
|
+
self.canvas.physics.step(nodes, links.values(), dt)
|
|
328
|
+
else:
|
|
329
|
+
self.canvas.tree_layout.apply(nodes, links.values())
|
|
330
|
+
self._draw(screen, nodes, links)
|
|
331
|
+
pygame.display.flip()
|
|
332
|
+
finally:
|
|
333
|
+
self.running = False
|
|
334
|
+
pygame.display.quit()
|
|
335
|
+
pygame.font.quit()
|
|
336
|
+
|
|
337
|
+
def stop(self) -> None:
|
|
338
|
+
self.running = False
|
|
339
|
+
|
|
340
|
+
def _events(self, screen: pygame.Surface) -> None:
|
|
341
|
+
for event in pygame.event.get():
|
|
342
|
+
if event.type == pygame.QUIT or (
|
|
343
|
+
event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE
|
|
344
|
+
):
|
|
345
|
+
self.running = False
|
|
346
|
+
elif event.type == pygame.KEYDOWN and event.key == pygame.K_g:
|
|
347
|
+
self.canvas.toggle_grid()
|
|
348
|
+
elif event.type == pygame.KEYDOWN and event.key == pygame.K_f:
|
|
349
|
+
self.canvas.controls = not self.canvas.controls
|
|
350
|
+
elif event.type == pygame.KEYDOWN and event.key == pygame.K_t:
|
|
351
|
+
next_layout = "tree" if self.canvas.layout == "force" else "force"
|
|
352
|
+
self.canvas.set_layout(next_layout)
|
|
353
|
+
elif self.canvas.controls and self.panel.handle(event, screen):
|
|
354
|
+
continue
|
|
355
|
+
elif event.type == pygame.MOUSEWHEEL:
|
|
356
|
+
before = self._from_screen(pygame.mouse.get_pos(), screen)
|
|
357
|
+
self.zoom = max(0.25, min(3.5, self.zoom * (1.12**event.y)))
|
|
358
|
+
after = self._from_screen(pygame.mouse.get_pos(), screen)
|
|
359
|
+
self.pan += (after - before) * self.zoom
|
|
360
|
+
elif event.type == pygame.MOUSEBUTTONDOWN:
|
|
361
|
+
if event.button == 1:
|
|
362
|
+
self._start_drag(event.pos, screen)
|
|
363
|
+
elif event.button in (2, 3):
|
|
364
|
+
self.panning = True
|
|
365
|
+
elif event.type == pygame.MOUSEBUTTONUP:
|
|
366
|
+
if event.button == 1 and self.dragged is not None:
|
|
367
|
+
self.dragged.fixed = self.drag_was_fixed
|
|
368
|
+
self.dragged = None
|
|
369
|
+
elif event.button in (2, 3):
|
|
370
|
+
self.panning = False
|
|
371
|
+
elif event.type == pygame.MOUSEMOTION:
|
|
372
|
+
if self.dragged is not None:
|
|
373
|
+
point = self._from_screen(event.pos, screen)
|
|
374
|
+
self.dragged.x, self.dragged.y = point.x, point.y
|
|
375
|
+
self.dragged.vx = self.dragged.vy = 0.0
|
|
376
|
+
elif self.panning:
|
|
377
|
+
self.pan += pygame.Vector2(event.rel)
|
|
378
|
+
|
|
379
|
+
def _start_drag(self, point: tuple[int, int], screen: pygame.Surface) -> None:
|
|
380
|
+
with self.canvas._locked_graph() as (nodes, _):
|
|
381
|
+
for node in reversed(tuple(nodes.values())):
|
|
382
|
+
if (
|
|
383
|
+
self._node_rect(node, screen, self._font_for_node(node))
|
|
384
|
+
.inflate(10, 10)
|
|
385
|
+
.collidepoint(point)
|
|
386
|
+
):
|
|
387
|
+
self.dragged = node
|
|
388
|
+
self.drag_was_fixed = node.fixed
|
|
389
|
+
node.fixed = True
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
def _draw(self, screen, nodes, links) -> None:
|
|
393
|
+
screen.fill(_color(self.canvas.background))
|
|
394
|
+
if self.canvas.grid:
|
|
395
|
+
self._draw_grid(screen)
|
|
396
|
+
for link in links.values():
|
|
397
|
+
source = nodes.get(link.source)
|
|
398
|
+
target = nodes.get(link.target)
|
|
399
|
+
if source is None or target is None:
|
|
400
|
+
continue
|
|
401
|
+
source_center = self._to_screen(source.position, screen)
|
|
402
|
+
target_center = self._to_screen(target.position, screen)
|
|
403
|
+
source_rect = self._node_rect(source, screen, self._font_for_node(source))
|
|
404
|
+
target_rect = self._node_rect(target, screen, self._font_for_node(target))
|
|
405
|
+
start = self._edge_point(source_rect, target_center)
|
|
406
|
+
end = self._edge_point(target_rect, source_center)
|
|
407
|
+
pygame.draw.aaline(screen, _color(link.color), start, end)
|
|
408
|
+
if link.width * self.zoom >= 2:
|
|
409
|
+
pygame.draw.line(
|
|
410
|
+
screen,
|
|
411
|
+
_color(link.color),
|
|
412
|
+
start,
|
|
413
|
+
end,
|
|
414
|
+
max(1, round(link.width * self.zoom)),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
mouse = pygame.mouse.get_pos()
|
|
418
|
+
hovered = None
|
|
419
|
+
for node in nodes.values():
|
|
420
|
+
node_font = self._font_for_node(node)
|
|
421
|
+
rect = self._node_rect(node, screen, node_font)
|
|
422
|
+
corner = max(0, round(self.canvas.node_roundness * self.zoom))
|
|
423
|
+
if rect.inflate(8, 8).collidepoint(mouse):
|
|
424
|
+
hovered = node
|
|
425
|
+
pygame.draw.rect(
|
|
426
|
+
screen, _color(node.color), rect.inflate(8, 8), 1, border_radius=corner + 3
|
|
427
|
+
)
|
|
428
|
+
shadow = rect.move(round(2 * self.zoom), round(3 * self.zoom))
|
|
429
|
+
pygame.draw.rect(screen, (3, 4, 8), shadow, border_radius=corner)
|
|
430
|
+
fill = _color(node.color)
|
|
431
|
+
pygame.draw.rect(screen, fill, rect, border_radius=corner)
|
|
432
|
+
pygame.draw.rect(screen, fill.lerp(pygame.Color("white"), 0.22), rect, 1, corner)
|
|
433
|
+
if self.zoom >= 0.38:
|
|
434
|
+
label = node_font.render(str(node.value), True, _text_color(fill))
|
|
435
|
+
screen.blit(label, label.get_rect(center=rect.center))
|
|
436
|
+
|
|
437
|
+
ui_scale = self.panel.scale(screen)
|
|
438
|
+
helper_font = self._font("sans", max(8, round(12 * ui_scale)))
|
|
439
|
+
margin = round(16 * ui_scale)
|
|
440
|
+
hint = "drag nodes • wheel zoom • right-drag pan • G grid • T view"
|
|
441
|
+
hint_surface = helper_font.render(hint, True, (100, 116, 139))
|
|
442
|
+
screen.blit(
|
|
443
|
+
hint_surface, (margin, screen.get_height() - hint_surface.get_height() - margin)
|
|
444
|
+
)
|
|
445
|
+
if hovered is not None and hovered.value != hovered.id:
|
|
446
|
+
id_surface = helper_font.render(hovered.id, True, (148, 163, 184))
|
|
447
|
+
screen.blit(id_surface, (margin, margin))
|
|
448
|
+
if self.canvas.controls:
|
|
449
|
+
self.panel.draw(screen)
|
|
450
|
+
|
|
451
|
+
def _font_for_node(self, node: Node) -> pygame.font.Font:
|
|
452
|
+
family = node.font or self.canvas.node_font
|
|
453
|
+
base_size = node.font_size or self.canvas.node_font_size
|
|
454
|
+
return self._font(family, max(7, min(120, round(base_size * self.zoom))))
|
|
455
|
+
|
|
456
|
+
def _font(self, family: str, size: int) -> pygame.font.Font:
|
|
457
|
+
key = family, size
|
|
458
|
+
if key not in self._font_cache:
|
|
459
|
+
self._font_cache[key] = pygame.font.SysFont(family, size)
|
|
460
|
+
return self._font_cache[key]
|
|
461
|
+
|
|
462
|
+
def _node_rect(
|
|
463
|
+
self, node: Node, screen: pygame.Surface, font: pygame.font.Font
|
|
464
|
+
) -> pygame.Rect:
|
|
465
|
+
label_width, label_height = font.size(str(node.value))
|
|
466
|
+
padding_x, padding_y = self.canvas.node_padding
|
|
467
|
+
width = max(
|
|
468
|
+
round(node.radius * 2 * self.zoom),
|
|
469
|
+
label_width + round(padding_x * 2 * self.zoom),
|
|
470
|
+
)
|
|
471
|
+
height = max(
|
|
472
|
+
round(node.radius * 2 * self.zoom),
|
|
473
|
+
label_height + round(padding_y * 2 * self.zoom),
|
|
474
|
+
)
|
|
475
|
+
width = max(4, width)
|
|
476
|
+
height = max(4, height)
|
|
477
|
+
return pygame.Rect((0, 0), (width, height)).move(
|
|
478
|
+
pygame.Vector2(self._to_screen(node.position, screen))
|
|
479
|
+
- pygame.Vector2(width / 2, height / 2)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def _edge_point(rect: pygame.Rect, toward: tuple[int, int]) -> tuple[int, int]:
|
|
484
|
+
center = pygame.Vector2(rect.center)
|
|
485
|
+
direction = pygame.Vector2(toward) - center
|
|
486
|
+
if direction.length_squared() == 0:
|
|
487
|
+
return rect.center
|
|
488
|
+
scale = 1.0 / max(
|
|
489
|
+
abs(direction.x) / max(rect.width / 2, 1),
|
|
490
|
+
abs(direction.y) / max(rect.height / 2, 1),
|
|
491
|
+
)
|
|
492
|
+
point = center + direction * scale
|
|
493
|
+
return round(point.x), round(point.y)
|
|
494
|
+
|
|
495
|
+
def _draw_grid(self, screen: pygame.Surface) -> None:
|
|
496
|
+
spacing = 40.0 * self.zoom
|
|
497
|
+
while spacing < 28:
|
|
498
|
+
spacing *= 2
|
|
499
|
+
while spacing > 80:
|
|
500
|
+
spacing /= 2
|
|
501
|
+
center_x = screen.get_width() / 2 + self.pan.x
|
|
502
|
+
center_y = screen.get_height() / 2 + self.pan.y
|
|
503
|
+
x = center_x % spacing
|
|
504
|
+
y = center_y % spacing
|
|
505
|
+
color = _color(self.canvas.grid_color)
|
|
506
|
+
axis_color = color.lerp(pygame.Color("#94a3b8"), 0.32)
|
|
507
|
+
while x < screen.get_width():
|
|
508
|
+
pygame.draw.line(screen, color, (round(x), 0), (round(x), screen.get_height()))
|
|
509
|
+
x += spacing
|
|
510
|
+
while y < screen.get_height():
|
|
511
|
+
pygame.draw.line(screen, color, (0, round(y)), (screen.get_width(), round(y)))
|
|
512
|
+
y += spacing
|
|
513
|
+
pygame.draw.line(
|
|
514
|
+
screen,
|
|
515
|
+
axis_color,
|
|
516
|
+
(round(center_x), 0),
|
|
517
|
+
(round(center_x), screen.get_height()),
|
|
518
|
+
)
|
|
519
|
+
pygame.draw.line(
|
|
520
|
+
screen,
|
|
521
|
+
axis_color,
|
|
522
|
+
(0, round(center_y)),
|
|
523
|
+
(screen.get_width(), round(center_y)),
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def _to_screen(self, point, screen: pygame.Surface) -> tuple[int, int]:
|
|
527
|
+
center = pygame.Vector2(screen.get_width() / 2, screen.get_height() / 2)
|
|
528
|
+
result = pygame.Vector2(point) * self.zoom + center + self.pan
|
|
529
|
+
return round(result.x), round(result.y)
|
|
530
|
+
|
|
531
|
+
def _from_screen(self, point, screen: pygame.Surface) -> pygame.Vector2:
|
|
532
|
+
center = pygame.Vector2(screen.get_width() / 2, screen.get_height() / 2)
|
|
533
|
+
return (pygame.Vector2(point) - center - self.pan) / self.zoom
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _color(value: Color) -> pygame.Color:
|
|
537
|
+
try:
|
|
538
|
+
return pygame.Color(value)
|
|
539
|
+
except (ValueError, TypeError) as error:
|
|
540
|
+
raise ValueError(f"invalid color {value!r}") from error
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _text_color(background: pygame.Color) -> pygame.Color:
|
|
544
|
+
luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b
|
|
545
|
+
return pygame.Color("#111827" if luminance > 155 else "#f8fafc")
|