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 ADDED
@@ -0,0 +1,7 @@
1
+ """A tiny, live, physics-based graph canvas."""
2
+
3
+ from .canvas import Canvas
4
+ from .models import Link, Node
5
+
6
+ __all__ = ["Canvas", "Link", "Node"]
7
+ __version__ = "0.1.0"
voidmesh/canvas.py ADDED
@@ -0,0 +1,439 @@
1
+ """The graph model and its live-window lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+ import threading
8
+ import uuid
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from dataclasses import replace
12
+ from typing import Any, Self
13
+
14
+ from .layout import TreeLayout
15
+ from .models import Color, Link, Node
16
+ from .physics import Physics
17
+
18
+
19
+ class Canvas:
20
+ """A mutable graph displayed on a clean, physics-based canvas.
21
+
22
+ Graph operations are thread-safe. Call ``show(block=False)`` to keep
23
+ executing Python while the window reflects later changes in real time.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ title: str = "Voidmesh",
30
+ width: int = 1000,
31
+ height: int = 700,
32
+ background: Color = "#090b12",
33
+ center_force: float = 0.12,
34
+ repel_force: float = 4.0,
35
+ link_force: float = 0.35,
36
+ link_distance: float = 150.0,
37
+ damping: float = 0.9,
38
+ layout: str = "force",
39
+ tree_horizontal_spacing: float = 150.0,
40
+ tree_vertical_spacing: float = 110.0,
41
+ grid: bool = False,
42
+ grid_color: Color = "#30384a",
43
+ controls: bool = True,
44
+ ui_scale: float | None = None,
45
+ node_font: str = "sans",
46
+ node_font_size: float = 15.0,
47
+ node_padding: tuple[float, float] = (12.0, 7.0),
48
+ node_roundness: float = 9.0,
49
+ link_width: float = 1.5,
50
+ fps: int = 60,
51
+ gravity: float | None = None,
52
+ repulsion: float | None = None,
53
+ ) -> None:
54
+ if width < 200 or height < 200:
55
+ raise ValueError("width and height must be at least 200")
56
+ if layout not in {"force", "tree"}:
57
+ raise ValueError("layout must be 'force' or 'tree'")
58
+ if node_font_size <= 0 or node_roundness < 0 or link_width <= 0:
59
+ raise ValueError(
60
+ "font size/link width must be positive; roundness cannot be negative"
61
+ )
62
+ if len(node_padding) != 2 or min(node_padding) < 0:
63
+ raise ValueError("node_padding must contain two non-negative values")
64
+ self.title = title
65
+ self.width = width
66
+ self.height = height
67
+ self.background = background
68
+ self.grid = grid
69
+ self.grid_color = grid_color
70
+ self.controls = controls
71
+ self.layout = layout
72
+ if ui_scale is not None and ui_scale <= 0:
73
+ raise ValueError("ui_scale must be positive")
74
+ self.ui_scale = ui_scale
75
+ self.node_font = node_font
76
+ self.node_font_size = float(node_font_size)
77
+ self.node_padding = (float(node_padding[0]), float(node_padding[1]))
78
+ self.node_roundness = float(node_roundness)
79
+ self.default_link_width = float(link_width)
80
+ self.fps = fps
81
+ self.nodes: dict[str, Node] = {}
82
+ self.links: dict[str, Link] = {}
83
+ self.physics = Physics(
84
+ center_force=center_force,
85
+ repel_force=repel_force,
86
+ link_force=link_force,
87
+ link_distance=link_distance,
88
+ damping=damping,
89
+ gravity=gravity,
90
+ repulsion=repulsion,
91
+ )
92
+ self.tree_layout = TreeLayout(
93
+ horizontal_spacing=tree_horizontal_spacing,
94
+ vertical_spacing=tree_vertical_spacing,
95
+ )
96
+ self._lock = threading.RLock()
97
+ self._window_thread: threading.Thread | None = None
98
+ self._renderer: Any = None
99
+ self._ready = threading.Event()
100
+ self._window_error: BaseException | None = None
101
+
102
+ def add_node(
103
+ self,
104
+ value: object = "",
105
+ *,
106
+ node_id: str | None = None,
107
+ color: Color = "#a78bfa",
108
+ position: tuple[float, float] | None = None,
109
+ radius: float = 10.0,
110
+ fixed: bool = False,
111
+ font: str | None = None,
112
+ font_size: float | None = None,
113
+ ) -> Node:
114
+ """Add and return a node."""
115
+ node_id = node_id or f"node-{uuid.uuid4().hex[:8]}"
116
+ if radius <= 0:
117
+ raise ValueError("radius must be positive")
118
+ if font_size is not None and font_size <= 0:
119
+ raise ValueError("font_size must be positive")
120
+ if position is None:
121
+ angle = random.random() * math.tau
122
+ distance = random.uniform(20.0, 85.0)
123
+ position = math.cos(angle) * distance, math.sin(angle) * distance
124
+ with self._lock:
125
+ if node_id in self.nodes:
126
+ raise ValueError(f"node {node_id!r} already exists")
127
+ node = Node(
128
+ id=node_id,
129
+ value=value,
130
+ color=color,
131
+ x=float(position[0]),
132
+ y=float(position[1]),
133
+ radius=float(radius),
134
+ fixed=fixed,
135
+ font=font,
136
+ font_size=float(font_size) if font_size is not None else None,
137
+ )
138
+ self.nodes[node_id] = node
139
+ return node
140
+
141
+ def update_node(self, node: str | Node, **changes: object) -> Node:
142
+ """Change a node's value, color, position, radius, or fixed state."""
143
+ allowed = {"value", "color", "position", "radius", "fixed", "font", "font_size"}
144
+ unknown = changes.keys() - allowed
145
+ if unknown:
146
+ raise TypeError(f"unknown node properties: {', '.join(sorted(unknown))}")
147
+ with self._lock:
148
+ current = self._get_node(node)
149
+ if "position" in changes:
150
+ position = changes.pop("position")
151
+ if not isinstance(position, (tuple, list)) or len(position) != 2:
152
+ raise ValueError("position must contain two numbers")
153
+ current.x, current.y = float(position[0]), float(position[1])
154
+ current.vx = current.vy = 0.0
155
+ if "radius" in changes and float(changes["radius"]) <= 0:
156
+ raise ValueError("radius must be positive")
157
+ if "radius" in changes:
158
+ changes["radius"] = float(changes["radius"])
159
+ if "font_size" in changes and changes["font_size"] is not None:
160
+ if float(changes["font_size"]) <= 0:
161
+ raise ValueError("font_size must be positive")
162
+ changes["font_size"] = float(changes["font_size"])
163
+ for name, value in changes.items():
164
+ setattr(current, name, value)
165
+ return current
166
+
167
+ def delete_node(self, node: str | Node) -> Node:
168
+ """Delete a node and all links attached to it."""
169
+ with self._lock:
170
+ current = self._get_node(node)
171
+ attached = [
172
+ link.id
173
+ for link in self.links.values()
174
+ if current.id in (link.source, link.target)
175
+ ]
176
+ for link_id in attached:
177
+ del self.links[link_id]
178
+ return self.nodes.pop(current.id)
179
+
180
+ remove_node = delete_node
181
+
182
+ def link(
183
+ self,
184
+ source: str | Node,
185
+ target: str | Node,
186
+ *,
187
+ link_id: str | None = None,
188
+ color: Color = "#64748b",
189
+ width: float | None = None,
190
+ length: float = 115.0,
191
+ elasticity: float = 0.035,
192
+ ) -> Link:
193
+ """Create and return an elastic link between two nodes."""
194
+ width = self.default_link_width if width is None else width
195
+ if width <= 0 or length <= 0 or elasticity < 0:
196
+ raise ValueError("width/length must be positive; elasticity cannot be negative")
197
+ with self._lock:
198
+ source_node = self._get_node(source)
199
+ target_node = self._get_node(target)
200
+ link_id = link_id or f"link-{uuid.uuid4().hex[:8]}"
201
+ if link_id in self.links:
202
+ raise ValueError(f"link {link_id!r} already exists")
203
+ link = Link(
204
+ id=link_id,
205
+ source=source_node.id,
206
+ target=target_node.id,
207
+ color=color,
208
+ width=float(width),
209
+ length=float(length),
210
+ elasticity=float(elasticity),
211
+ )
212
+ self.links[link_id] = link
213
+ return link
214
+
215
+ def update_link(self, link: str | Link, **changes: object) -> Link:
216
+ """Change a link's color, width, length, or elasticity."""
217
+ allowed = {"color", "width", "length", "elasticity"}
218
+ unknown = changes.keys() - allowed
219
+ if unknown:
220
+ raise TypeError(f"unknown link properties: {', '.join(sorted(unknown))}")
221
+ if "width" in changes and float(changes["width"]) <= 0:
222
+ raise ValueError("width must be positive")
223
+ if "length" in changes and float(changes["length"]) <= 0:
224
+ raise ValueError("length must be positive")
225
+ if "elasticity" in changes and float(changes["elasticity"]) < 0:
226
+ raise ValueError("elasticity cannot be negative")
227
+ with self._lock:
228
+ current = self._get_link(link)
229
+ for name, value in changes.items():
230
+ setattr(current, name, float(value) if name != "color" else value)
231
+ return current
232
+
233
+ def unlink(
234
+ self, link_or_source: str | Node | Link, target: str | Node | None = None
235
+ ) -> Link:
236
+ """Remove a link by link/id, or remove the first link between two nodes."""
237
+ with self._lock:
238
+ if target is None:
239
+ link = self._get_link(link_or_source)
240
+ else:
241
+ source_id = self._get_node(link_or_source).id
242
+ target_id = self._get_node(target).id
243
+ link = next(
244
+ (
245
+ item
246
+ for item in self.links.values()
247
+ if {item.source, item.target} == {source_id, target_id}
248
+ ),
249
+ None,
250
+ )
251
+ if link is None:
252
+ raise KeyError(f"no link between {source_id!r} and {target_id!r}")
253
+ return self.links.pop(link.id)
254
+
255
+ delete_link = unlink
256
+
257
+ def relink(
258
+ self,
259
+ link: str | Link,
260
+ *,
261
+ source: str | Node | None = None,
262
+ target: str | Node | None = None,
263
+ ) -> Link:
264
+ """Move either end of an existing link."""
265
+ with self._lock:
266
+ current = self._get_link(link)
267
+ if source is not None:
268
+ current.source = self._get_node(source).id
269
+ if target is not None:
270
+ current.target = self._get_node(target).id
271
+ return current
272
+
273
+ def clear(self) -> None:
274
+ """Remove every node and link."""
275
+ with self._lock:
276
+ self.nodes.clear()
277
+ self.links.clear()
278
+
279
+ def configure_physics(
280
+ self,
281
+ *,
282
+ center_force: float | None = None,
283
+ repel_force: float | None = None,
284
+ link_force: float | None = None,
285
+ link_distance: float | None = None,
286
+ damping: float | None = None,
287
+ ) -> None:
288
+ """Adjust the live force simulation programmatically."""
289
+ changes = {
290
+ "center_force": center_force,
291
+ "repel_force": repel_force,
292
+ "link_force": link_force,
293
+ "link_distance": link_distance,
294
+ "damping": damping,
295
+ }
296
+ with self._lock:
297
+ for name, value in changes.items():
298
+ if value is None:
299
+ continue
300
+ if value < 0 or (name == "link_distance" and value == 0):
301
+ raise ValueError(f"{name} must be positive or zero")
302
+ setattr(self.physics, name, float(value))
303
+
304
+ def toggle_grid(self, visible: bool | None = None) -> bool:
305
+ """Toggle the background grid and return its new visibility."""
306
+ with self._lock:
307
+ self.grid = not self.grid if visible is None else bool(visible)
308
+ return self.grid
309
+
310
+ def set_layout(self, layout: str) -> None:
311
+ """Switch between the live ``force`` view and systematic ``tree`` view."""
312
+ if layout not in {"force", "tree"}:
313
+ raise ValueError("layout must be 'force' or 'tree'")
314
+ with self._lock:
315
+ self.layout = layout
316
+ if layout == "tree":
317
+ self.tree_layout.apply(self.nodes, self.links.values())
318
+
319
+ def configure_style(
320
+ self,
321
+ *,
322
+ node_font: str | None = None,
323
+ node_font_size: float | None = None,
324
+ node_padding: tuple[float, float] | None = None,
325
+ node_roundness: float | None = None,
326
+ link_width: float | None = None,
327
+ grid_color: Color | None = None,
328
+ ) -> None:
329
+ """Change the graph's visual defaults while its window is open."""
330
+ if node_font_size is not None and node_font_size <= 0:
331
+ raise ValueError("node_font_size must be positive")
332
+ if node_roundness is not None and node_roundness < 0:
333
+ raise ValueError("node_roundness cannot be negative")
334
+ if node_padding is not None and (len(node_padding) != 2 or min(node_padding) < 0):
335
+ raise ValueError("node_padding must contain two non-negative values")
336
+ if link_width is not None and link_width <= 0:
337
+ raise ValueError("link_width must be positive")
338
+ with self._lock:
339
+ if node_font is not None:
340
+ self.node_font = node_font
341
+ if node_font_size is not None:
342
+ self.node_font_size = float(node_font_size)
343
+ if node_padding is not None:
344
+ self.node_padding = (float(node_padding[0]), float(node_padding[1]))
345
+ if node_roundness is not None:
346
+ self.node_roundness = float(node_roundness)
347
+ if link_width is not None:
348
+ self.default_link_width = float(link_width)
349
+ for link in self.links.values():
350
+ link.width = float(link_width)
351
+ if grid_color is not None:
352
+ self.grid_color = grid_color
353
+
354
+ def snapshot(self) -> tuple[tuple[Node, ...], tuple[Link, ...]]:
355
+ """Return detached copies of the current graph state."""
356
+ with self._lock:
357
+ return (
358
+ tuple(replace(node) for node in self.nodes.values()),
359
+ tuple(replace(link) for link in self.links.values()),
360
+ )
361
+
362
+ @contextmanager
363
+ def _locked_graph(self) -> Iterator[tuple[dict[str, Node], dict[str, Link]]]:
364
+ with self._lock:
365
+ yield self.nodes, self.links
366
+
367
+ def show(self, *, block: bool = True) -> Canvas:
368
+ """Open the live canvas; non-blocking mode lets the script keep running."""
369
+ if self.is_open:
370
+ return self
371
+ self._ready.clear()
372
+ self._window_error = None
373
+ if block:
374
+ self._run_window()
375
+ else:
376
+ self._window_thread = threading.Thread(
377
+ target=self._run_window, name="voidmesh-window", daemon=True
378
+ )
379
+ self._window_thread.start()
380
+ ready = self._ready.wait(timeout=5.0)
381
+ if self._window_error is not None:
382
+ raise RuntimeError("could not open the voidmesh window") from self._window_error
383
+ if not ready:
384
+ self.close()
385
+ raise TimeoutError("the voidmesh window did not open within 5 seconds")
386
+ return self
387
+
388
+ run = show
389
+
390
+ def close(self) -> None:
391
+ """Ask the live window to close."""
392
+ renderer = self._renderer
393
+ if renderer is not None:
394
+ renderer.stop()
395
+ thread = self._window_thread
396
+ if thread is not None and thread is not threading.current_thread():
397
+ thread.join(timeout=2.0)
398
+
399
+ def wait(self) -> None:
400
+ """Wait for a non-blocking window to be closed by the user."""
401
+ thread = self._window_thread
402
+ if thread is not None and thread is not threading.current_thread():
403
+ thread.join()
404
+
405
+ @property
406
+ def is_open(self) -> bool:
407
+ return self._renderer is not None and self._renderer.running
408
+
409
+ def _run_window(self) -> None:
410
+ try:
411
+ from .renderer import Renderer
412
+
413
+ self._renderer = Renderer(self)
414
+ self._renderer.run()
415
+ except BaseException as error:
416
+ self._window_error = error
417
+ self._ready.set()
418
+ if self._window_thread is None:
419
+ raise
420
+ finally:
421
+ self._renderer = None
422
+
423
+ def _get_node(self, node: str | Node | Link) -> Node:
424
+ node_id = node.id if isinstance(node, Node) else node
425
+ if not isinstance(node_id, str) or node_id not in self.nodes:
426
+ raise KeyError(f"unknown node {node_id!r}")
427
+ return self.nodes[node_id]
428
+
429
+ def _get_link(self, link: str | Node | Link) -> Link:
430
+ link_id = link.id if isinstance(link, Link) else link
431
+ if not isinstance(link_id, str) or link_id not in self.links:
432
+ raise KeyError(f"unknown link {link_id!r}")
433
+ return self.links[link_id]
434
+
435
+ def __enter__(self) -> Self:
436
+ return self.show(block=False)
437
+
438
+ def __exit__(self, *_: object) -> None:
439
+ self.close()
voidmesh/demo.py ADDED
@@ -0,0 +1,23 @@
1
+ """The bundled ``voidmesh-demo`` example."""
2
+
3
+ from .canvas import Canvas
4
+
5
+
6
+ def main() -> None:
7
+ canvas = Canvas(title="Voidmesh — live graph")
8
+ center = canvas.add_node("Voidmesh", color="#f8fafc", radius=14, fixed=True)
9
+ ideas = [
10
+ ("Nodes", "#a78bfa"),
11
+ ("Links", "#38bdf8"),
12
+ ("Physics", "#34d399"),
13
+ ("Live", "#fb7185"),
14
+ ("Python", "#fbbf24"),
15
+ ]
16
+ for label, color in ideas:
17
+ node = canvas.add_node(label, color=color)
18
+ canvas.link(center, node, color=color, length=145)
19
+ canvas.show()
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
voidmesh/layout.py ADDED
@@ -0,0 +1,92 @@
1
+ """Deterministic, non-physical graph layouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from .models import Link, Node
8
+
9
+
10
+ class TreeLayout:
11
+ """Lay directed source-to-target links out as a top-down forest."""
12
+
13
+ def __init__(
14
+ self, *, horizontal_spacing: float = 150, vertical_spacing: float = 110
15
+ ) -> None:
16
+ self.horizontal_spacing = horizontal_spacing
17
+ self.vertical_spacing = vertical_spacing
18
+
19
+ def apply(self, nodes: dict[str, Node], links: Iterable[Link]) -> None:
20
+ if not nodes:
21
+ return
22
+
23
+ children = {node_id: [] for node_id in nodes}
24
+ parent: dict[str, str] = {}
25
+ for link in links:
26
+ if (
27
+ link.source in nodes
28
+ and link.target in nodes
29
+ and link.source != link.target
30
+ and link.target not in parent
31
+ ):
32
+ parent[link.target] = link.source
33
+ children[link.source].append(link.target)
34
+
35
+ roots = [node_id for node_id in nodes if node_id not in parent]
36
+ if not roots:
37
+ roots = [next(iter(nodes))]
38
+
39
+ positions: dict[str, tuple[float, int]] = {}
40
+ placed: set[str] = set()
41
+ cursor = 0.0
42
+ max_depth = 0
43
+
44
+ def place(node_id: str, depth: int, left: float, visiting: set[str]) -> float:
45
+ nonlocal max_depth
46
+ if node_id in placed or node_id in visiting:
47
+ return 0.0
48
+ visiting.add(node_id)
49
+ placed.add(node_id)
50
+ max_depth = max(max_depth, depth)
51
+ valid_children = [child for child in children[node_id] if child not in placed]
52
+ own_width = max(
53
+ self.horizontal_spacing,
54
+ len(str(nodes[node_id].value)) * 9.0 + 54.0,
55
+ )
56
+ if not valid_children:
57
+ positions[node_id] = (left + own_width / 2, depth)
58
+ visiting.remove(node_id)
59
+ return own_width
60
+
61
+ child_left = left
62
+ child_centers: list[float] = []
63
+ total_width = 0.0
64
+ for child in valid_children:
65
+ width = place(child, depth + 1, child_left, visiting)
66
+ if width > 0:
67
+ child_centers.append(positions[child][0])
68
+ child_left += width
69
+ total_width += width
70
+ width = max(own_width, total_width)
71
+ center = (
72
+ sum(child_centers) / len(child_centers) if child_centers else left + width / 2
73
+ )
74
+ positions[node_id] = (center, depth)
75
+ visiting.remove(node_id)
76
+ return width
77
+
78
+ for root in roots:
79
+ width = place(root, 0, cursor, set())
80
+ cursor += width + self.horizontal_spacing * 0.6
81
+ for node_id in nodes:
82
+ if node_id not in placed:
83
+ width = place(node_id, 0, cursor, set())
84
+ cursor += width + self.horizontal_spacing * 0.6
85
+
86
+ total_width = max(cursor - self.horizontal_spacing * 0.6, 0.0)
87
+ y_offset = max_depth * self.vertical_spacing / 2
88
+ for node_id, (x, depth) in positions.items():
89
+ node = nodes[node_id]
90
+ node.x = x - total_width / 2
91
+ node.y = depth * self.vertical_spacing - y_offset
92
+ node.vx = node.vy = 0.0
voidmesh/models.py ADDED
@@ -0,0 +1,41 @@
1
+ """Public graph data types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ type Color = str | tuple[int, int, int]
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class Node:
12
+ """A node on a :class:`voidmesh.Canvas`."""
13
+
14
+ id: str
15
+ value: object = ""
16
+ color: Color = "#a78bfa"
17
+ x: float = 0.0
18
+ y: float = 0.0
19
+ radius: float = 10.0
20
+ fixed: bool = False
21
+ font: str | None = None
22
+ font_size: float | None = None
23
+ vx: float = field(default=0.0, repr=False)
24
+ vy: float = field(default=0.0, repr=False)
25
+
26
+ @property
27
+ def position(self) -> tuple[float, float]:
28
+ return self.x, self.y
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class Link:
33
+ """An elastic connection between two nodes."""
34
+
35
+ id: str
36
+ source: str
37
+ target: str
38
+ color: Color = "#64748b"
39
+ width: float = 1.5
40
+ length: float = 115.0
41
+ elasticity: float = 0.035