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/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")